diff --git a/.npmrc b/.npmrc index e29745a..72eb5e3 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ engine-strict=true + diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c4f9d..6e32342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Smart Operators**: Reusable AI personas with drag-and-drop assignment - **Project Memory System**: Automatic context management with Curator - **MCP Server Integration**: Filesystem, Shell, Git, HTTP Fetch, and third-party tools -- **Local Agent Support**: Antigravity and Claude-Code integrations +- **Local Agent Support**: Claude-Code integrations - **Marketplace**: Share and discover workflows, operators, and templates - **Four Task Types**: AI tasks, Script tasks (JavaScript), Input tasks, Output tasks - **Webhooks**: Task completion notifications diff --git a/electron/main/config/provider-registry.ts b/electron/main/config/provider-registry.ts index 915f0f5..ab47126 100644 --- a/electron/main/config/provider-registry.ts +++ b/electron/main/config/provider-registry.ts @@ -76,6 +76,13 @@ export const PROVIDER_REGISTRY: Record = { executionStrategy: 'local-session', agentCommand: 'codex', }, + 'gemini-cli': { + id: 'gemini-cli', + name: 'Gemini CLI', + type: 'local-agent', + executionStrategy: 'local-session', + agentCommand: 'gemini', + }, }; /** @@ -104,14 +111,15 @@ export function isApiProvider(providerId: string): boolean { /** * Get local agent type for provider */ -export function getLocalAgentType(providerId: string): 'claude' | 'codex' | null { +export function getLocalAgentType(providerId: string): 'claude' | 'codex' | 'gemini-cli' | null { const config = getProviderConfig(providerId); if (config?.type !== 'local-agent') return null; // Map provider ID to agent type - const agentMap: Record = { + const agentMap: Record = { 'claude-code': 'claude', codex: 'codex', + 'gemini-cli': 'gemini-cli', }; return agentMap[providerId] || null; diff --git a/electron/main/database/repositories/task-history-repository.ts b/electron/main/database/repositories/task-history-repository.ts index bdb7e7f..9f2a7bd 100644 --- a/electron/main/database/repositories/task-history-repository.ts +++ b/electron/main/database/repositories/task-history-repository.ts @@ -39,6 +39,20 @@ export class TaskHistoryRepository { return result[0]!; } + async findByProject(projectId: number, limit?: number): Promise { + let query = db + .select() + .from(taskHistory) + .where(eq(taskHistory.taskProjectId, projectId)) + .orderBy(desc(taskHistory.createdAt), desc(taskHistory.id)); + + if (limit) { + query = query.limit(limit) as typeof query; + } + + return await query; + } + /** * Find all history entries for a task */ diff --git a/electron/main/database/repositories/task-repository.ts b/electron/main/database/repositories/task-repository.ts index 985332a..d718012 100644 --- a/electron/main/database/repositories/task-repository.ts +++ b/electron/main/database/repositories/task-repository.ts @@ -79,15 +79,25 @@ export class TaskRepository { const [result] = await db .select() .from(tasks) - .where( - and( - eq(tasks.projectId, projectId), - eq(tasks.projectSequence, projectSequence), - isNull(tasks.deletedAt) - ) - ) + .where(and(eq(tasks.projectId, projectId), eq(tasks.projectSequence, projectSequence))) .limit(1); + if (result?.executionResult?.content) { + const safeResult = { + ...result, + executionResult: { ...result.executionResult, content: '(truncated)' }, + }; + console.log( + `[TaskRepo] findByKey(${projectId}, ${projectSequence}) raw result:`, + safeResult + ); + } else { + console.log( + `[TaskRepo] findByKey(${projectId}, ${projectSequence}) raw result:`, + result + ); + } + return result; } @@ -244,14 +254,110 @@ export class TaskRepository { /** * Soft delete task by composite key */ + /** + * Soft delete task by composite key and clean up dependencies + */ async deleteByKey(projectId: number, projectSequence: number): Promise { - await db - .update(tasks) - .set({ - deletedAt: new Date(), - updatedAt: new Date(), - }) - .where(and(eq(tasks.projectId, projectId), eq(tasks.projectSequence, projectSequence))); + // 1. Find tasks that depend on this task + const dependentTasks = await db + .select() + .from(tasks) + .where( + and( + eq(tasks.projectId, projectId), + isNull(tasks.deletedAt) + // We can't easily filter JSON in SQLite efficiently for all cases, + // so we'll fetch project tasks and filter in memory or rely on broader fetch. + // Given findByProject usage pattern, fetching all active tasks for project is safe/standard here. + ) + ); + + const tasksToUpdate_TriggerConfig: Task[] = []; + const tasksToUpdate_Dependencies: Task[] = []; + + for (const task of dependentTasks) { + // Check triggerConfig + if ( + task.triggerConfig && + task.triggerConfig.dependsOn && + Array.isArray(task.triggerConfig.dependsOn.taskIds) + ) { + if (task.triggerConfig.dependsOn.taskIds.includes(projectSequence)) { + tasksToUpdate_TriggerConfig.push(task); + } + } + + // Check dependencies (legacy or simple array) + if (Array.isArray(task.dependencies) && task.dependencies.includes(projectSequence)) { + tasksToUpdate_Dependencies.push(task); + } + } + + // 2. Update dependent tasks + await db.transaction(async (tx) => { + // Update triggerConfigs + for (const task of tasksToUpdate_TriggerConfig) { + if (!task.triggerConfig?.dependsOn?.taskIds) continue; + + const newTaskIds = task.triggerConfig.dependsOn.taskIds.filter( + (id) => id !== projectSequence + ); + const newTriggerConfig = { + ...task.triggerConfig, + dependsOn: { + ...task.triggerConfig.dependsOn, + taskIds: newTaskIds, + }, + }; + + // If no dependencies left, remove dependsOn entirely? + // Or keep empty array? Keeping empty array might be safer or user might want to add more. + // User request says "remove from dependency". + // If taskIds becomes empty, the task might auto-trigger or never trigger depending on logic. + // Usually empty dependency means "no dependency". + + await tx + .update(tasks) + .set({ + triggerConfig: newTriggerConfig, + updatedAt: new Date(), + }) + .where( + and( + eq(tasks.projectId, projectId), + eq(tasks.projectSequence, task.projectSequence) + ) + ); + } + + // Update dependencies column + for (const task of tasksToUpdate_Dependencies) { + const newDeps = (task.dependencies || []).filter((id) => id !== projectSequence); + await tx + .update(tasks) + .set({ + dependencies: newDeps, + updatedAt: new Date(), + }) + .where( + and( + eq(tasks.projectId, projectId), + eq(tasks.projectSequence, task.projectSequence) + ) + ); + } + + // 3. Perform the soft delete + await tx + .update(tasks) + .set({ + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where( + and(eq(tasks.projectId, projectId), eq(tasks.projectSequence, projectSequence)) + ); + }); } /** diff --git a/electron/main/index.ts b/electron/main/index.ts index a106a25..795cecc 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -26,6 +26,7 @@ import { registerLocalProviderHandlers } from './ipc/local-providers-handlers'; import { registerAuthHandlers } from './ipc/auth-handlers'; import { registerAiSettingsHandlers } from './ipc/ai-settings-handlers'; import { registerScriptTemplateHandlers } from './ipc/script-template-handlers'; +import { registerTerminalHandlers } from './services/terminal'; // import { registerHttpHandlers } from './ipc/http-handlers'; import { seedDatabase } from './database/seed'; import type { NewTask, Task } from './database/schema'; @@ -219,9 +220,56 @@ async function registerIpcHandlers(): Promise { ownerId: number; baseDevFolder?: string | null; projectGuidelines?: string | null; + // Scanner overrides + goal?: string; + memory?: any; } ) => { try { + // If baseDevFolder is provided, try to scan for local agent context + if (data.baseDevFolder) { + try { + const { localAgentScanner } = + await import('./services/local-agent-scanner'); + console.log( + `[ProjectCreate] Scanning folder for local agents: ${data.baseDevFolder}` + ); + const context = await localAgentScanner.scanFolder(data.baseDevFolder); + + if (context) { + console.log(`[ProjectCreate] Found context from ${context.source}`); + // Auto-populate goal if empty + if (!data.goal && context.goal) { + data.goal = context.goal; + } + + // Auto-populate memory if empty + // Convert string memory to required JSON structure or just store as is if schema allowed text + // Schema says memory is json: { summary: string, ... } + if (!data.memory && context.memory) { + data.memory = { + summary: context.memory, + importedFrom: context.source, + scannedAt: new Date().toISOString(), + // Use gathered context as 'long term memory' or just append to summary + }; + } + + // Append to description if sensible + // MOVED TO projectGuidelines + if (context.guidelines) { + (data as any).projectGuidelines = context.guidelines; + } + } + } catch (scanErr) { + console.warn( + '[ProjectCreate] Failed to scan local agent context:', + scanErr + ); + // Continue creation even if scan fails + } + } + const project = await projectRepo.create(data as any); mainWindow?.webContents.send('project:created', project); return project; @@ -386,6 +434,205 @@ async function registerIpcHandlers(): Promise { } ); + ipcMain.handle('projects:sync-local-context', async (_event, projectId: number) => { + try { + const project = await projectRepo.findById(projectId); + if (!project) throw new Error('Project not found'); + if (!project.baseDevFolder) { + console.warn('[SyncContext] Project has no baseDevFolder'); + return { success: false, message: 'No base folder set' }; + } + + const { localAgentScanner } = await import('./services/local-agent-scanner'); + console.log(`[SyncContext] Scanning folder for local agents: ${project.baseDevFolder}`); + const context = await localAgentScanner.scanFolder(project.baseDevFolder); + + if (context) { + console.log(`[SyncContext] Found context from ${context.source}`); + const updateData: any = {}; + + // Update goal if present + if (context.goal) { + updateData.goal = context.goal; + } + + // Update memory if present + if (context.memory) { + updateData.memory = { + summary: context.memory, + importedFrom: context.source, + scannedAt: new Date().toISOString(), + }; + } + + // Update projectGuidelines if present + if (context.guidelines) { + updateData.projectGuidelines = context.guidelines; + } + + if (Object.keys(updateData).length > 0) { + const updatedProject = await projectRepo.update(projectId, updateData); + mainWindow?.webContents.send('project:updated', updatedProject); + return { success: true, context, project: updatedProject }; + } + } + + return { success: false, message: 'No local agent context found' }; + } catch (error) { + console.error('Error syncing local context:', error); + throw error; + } + }); + + ipcMain.handle('projects:scan-artifacts', async (_event, projectId: number) => { + try { + const project = await projectRepo.findById(projectId); + if (!project || !project.baseDevFolder) { + return { success: false, message: 'Project or base folder not found' }; + } + + const { localAgentScanner } = await import('./services/local-agent-scanner'); + const artifacts = await localAgentScanner.scanProjectArtifacts(project.baseDevFolder); + + return { success: true, artifacts }; + } catch (error) { + console.error('Error scanning artifacts:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + + // Detect existing agent context for project recovery + ipcMain.handle('projects:detect-context', async (_event, projectId: number) => { + try { + const project = await projectRepo.findById(projectId); + if (!project || !project.baseDevFolder) { + return { hasContext: false }; + } + + const folder = project.baseDevFolder; + const fs = await import('fs/promises'); + const path = await import('path'); + + // Helper to check if path exists + const exists = async (p: string) => { + try { + await fs.access(p); + return true; + } catch { + return false; + } + }; + + // Helper to read file safely + const readFileSafe = async (p: string): Promise => { + try { + return await fs.readFile(p, 'utf-8'); + } catch { + return null; + } + }; + + // Detect various context files + const hasClaudeMd = await exists(path.join(folder, 'CLAUDE.md')); + const hasGeminiDir = await exists(path.join(folder, '.gemini')); + const hasCodexDir = await exists(path.join(folder, '.codex')); + const hasGit = await exists(path.join(folder, '.git')); + const hasGeminiMd = await exists(path.join(folder, 'GEMINI.md')); + const hasAgentsMd = await exists(path.join(folder, 'AGENTS.md')); + const hasTaskMd = await exists(path.join(folder, 'task.md')); + const hasPlanMd = await exists(path.join(folder, 'implementation_plan.md')); + const hasReadme = await exists(path.join(folder, 'README.md')); + + // Read Claude CLAUDE.md content + const claudeMdContent = hasClaudeMd + ? await readFileSafe(path.join(folder, 'CLAUDE.md')) + : null; + + // Read Gemini GEMINI.md content + const geminiMdContent = hasGeminiMd + ? await readFileSafe(path.join(folder, 'GEMINI.md')) + : null; + + // Read Codex AGENTS.md content (OpenAI Codex uses AGENTS.md) + const agentsMdContent = hasAgentsMd + ? await readFileSafe(path.join(folder, 'AGENTS.md')) + : null; + + // Read task.md for Gemini CLI progress context + const taskMdContent = hasTaskMd + ? await readFileSafe(path.join(folder, 'task.md')) + : null; + + // Read implementation_plan.md for Gemini CLI progress + const planMdContent = hasPlanMd + ? await readFileSafe(path.join(folder, 'implementation_plan.md')) + : null; + + // Check .gemini/task.md as alternative location + let geminiTaskMdContent: string | null = null; + if (hasGeminiDir) { + geminiTaskMdContent = await readFileSafe(path.join(folder, '.gemini', 'task.md')); + } + + // Get recent git commits if git exists + let recentCommits: string[] = []; + if (hasGit) { + try { + const { exec } = await import('child_process'); + const { promisify } = await import('util'); + const execAsync = promisify(exec); + const { stdout } = await execAsync('git log --oneline -n 5', { cwd: folder }); + recentCommits = stdout + .trim() + .split('\n') + .filter((l: string) => l); + } catch (e) { + console.warn('Could not get git history:', e); + } + } + + const hasContext = + hasClaudeMd || + hasGeminiDir || + hasCodexDir || + hasGeminiMd || + hasAgentsMd || + hasTaskMd || + hasPlanMd; + + return { + hasContext, + // Detection flags + hasClaudeMd, + hasGeminiDir, + hasCodexDir, + hasGit, + hasGeminiMd, + hasAgentsMd, + hasTaskMd, + hasPlanMd, + hasReadme, + // Content + claudeMdContent, + geminiMdContent, + agentsMdContent, + taskMdContent, + planMdContent, + geminiTaskMdContent, + recentCommits, + }; + } catch (error) { + console.error('Error detecting context:', error); + return { + hasContext: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + // ======================================== // Task IPC Handlers // ======================================== @@ -663,6 +910,9 @@ async function registerIpcHandlers(): Promise { // Register script template handlers registerScriptTemplateHandlers(); + // Register terminal handlers + registerTerminalHandlers(mainWindow); + console.log('IPC handlers registered'); } diff --git a/electron/main/ipc/local-agents-handlers.ts b/electron/main/ipc/local-agents-handlers.ts index 3d955ae..b759907 100644 --- a/electron/main/ipc/local-agents-handlers.ts +++ b/electron/main/ipc/local-agents-handlers.ts @@ -43,7 +43,7 @@ async function checkCommandInstalled(command: string): Promise } // Whitelist of allowed commands for security - const allowedCommands = ['claude', 'codex', 'gemini']; + const allowedCommands = ['claude', 'codex', 'gemini', 'gemini-cli']; if (!allowedCommands.includes(command)) { console.warn(`Command not in whitelist: ${command}`); @@ -52,14 +52,24 @@ async function checkCommandInstalled(command: string): Promise return result; } + // Map internal IDs to actual CLI commands + const commandMap: Record = { + 'gemini-cli': 'gemini', + }; + + const actualCommand = commandMap[command] || command; + const enhancedPath = getEnhancedPath(); - // console.log(`[LocalAgents] Checking ${command} with PATH: ...`); + // console.log(`[LocalAgents] Checking ${actualCommand} with PATH: ...`); + + // Use actualCommand for execution + const cmdToCheck = actualCommand; let result: AgentCheckResult; try { // Try to get version using --version flag - const { stdout } = await execAsync(`${command} --version`, { + const { stdout } = await execAsync(`${cmdToCheck} --version`, { timeout: 5000, env: { ...process.env, PATH: enhancedPath }, }); @@ -76,14 +86,14 @@ async function checkCommandInstalled(command: string): Promise // If --version fails, try which/where command try { const whichCmd = process.platform === 'win32' ? 'where' : 'which'; - const { stdout } = await execAsync(`${whichCmd} ${command}`, { + const { stdout } = await execAsync(`${whichCmd} ${cmdToCheck}`, { timeout: 5000, env: { ...process.env, PATH: enhancedPath }, }); - console.log(`[LocalAgents] ${whichCmd} ${command} found:`, stdout.trim()); + console.log(`[LocalAgents] ${whichCmd} ${cmdToCheck} found:`, stdout.trim()); result = { installed: true }; } catch (whichError) { - console.log(`[LocalAgents] ${command} not found:`, (whichError as Error).message); + console.log(`[LocalAgents] ${cmdToCheck} not found:`, (whichError as Error).message); result = { installed: false }; } } diff --git a/electron/main/ipc/local-providers-handlers.ts b/electron/main/ipc/local-providers-handlers.ts index b512a54..fa75267 100644 --- a/electron/main/ipc/local-providers-handlers.ts +++ b/electron/main/ipc/local-providers-handlers.ts @@ -119,4 +119,49 @@ export function registerLocalProviderHandlers(): void { return []; } }); + + // Save models to cache (DB) + ipcMain.handle( + 'ai:saveModelsToCache', + async (_event, providerId: string, models: any[]): Promise => { + try { + const { providerModelsRepository } = + await import('../database/repositories/provider-models-repository'); + await providerModelsRepository.saveModels(providerId, models); + } catch (error) { + console.error(`[AI] Failed to save cached models for ${providerId}:`, error); + } + } + ); + + // Get API key from system environment (Bridge for external auth) + ipcMain.handle( + 'ai:getEnvApiKey', + async (_event, providerId: string): Promise => { + try { + console.log(`[IPC] ai:getEnvApiKey called for ${providerId}`); + // Only expose specific keys for security + if (providerId === 'google') { + const key = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY; + console.log(`[IPC] Key found for google? ${!!key}`); + return key || null; + } + if (providerId === 'gemini-cli') { + const key = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; + console.log(`[IPC] Key found for gemini-cli? ${!!key}`); + return key || null; + } + if (providerId === 'openai') { + return process.env.OPENAI_API_KEY || null; + } + if (providerId === 'anthropic') { + return process.env.ANTHROPIC_API_KEY || null; + } + return null; + } catch (error) { + console.error(`[AI] Failed to get env key for ${providerId}:`, error); + return null; + } + } + ); } diff --git a/electron/main/ipc/task-execution-handlers.ts b/electron/main/ipc/task-execution-handlers.ts index 964b12f..8418ce3 100644 --- a/electron/main/ipc/task-execution-handlers.ts +++ b/electron/main/ipc/task-execution-handlers.ts @@ -659,6 +659,17 @@ const triggerCurator = async ( // Fetch API keys from renderer localStorage to inject into Curator const win = getMainWindow(); let apiKeys: any = {}; + let defaultAiConfig: { providerId: string; modelId: string } | null = null; + let isLoggedIn = false; + + // Check login status (Main Process Source of Truth) + try { + const { getCurrentUser } = await import('../auth/google-oauth'); + const user = await getCurrentUser(); + isLoggedIn = !!user; + } catch (e) { + console.warn('[CuratorTrigger] Failed to check login status:', e); + } if (win) { try { @@ -687,6 +698,28 @@ const triggerCurator = async ( '[CuratorTrigger] Injected API keys (merged):', Object.keys(apiKeys).filter((k) => !!apiKeys[k]) ); + + // Determine Default AI Provider (First Enabled) + // Replicates settingsStore.ts logic + const enabledProvider = providers.find((p: any) => { + if (!p.enabled) return false; + if (['ollama', 'lmstudio', 'default-highflow'].includes(p.id)) { + return true; + } + // For others, require key or connection + return !!p.apiKey || p.isConnected; + }); + + if (enabledProvider) { + defaultAiConfig = { + providerId: enabledProvider.id, + modelId: enabledProvider.defaultModel, + }; + console.log( + '[CuratorTrigger] Detected User Default AI:', + defaultAiConfig + ); + } } } catch (e) { console.warn('[CuratorTrigger] Failed to fetch API keys from renderer:', e); @@ -696,8 +729,6 @@ const triggerCurator = async ( const curator = CuratorService.getInstance(); curator.setApiKeys(apiKeys); - curator.setApiKeys(apiKeys); - await curator.runCurator( projectId, sequence, @@ -706,7 +737,9 @@ const triggerCurator = async ( project, null, projectRepository, - preComputedContext + preComputedContext, + defaultAiConfig, + isLoggedIn ); // Update metadata with new hash to prevent duplicates @@ -840,6 +873,10 @@ async function processInputSubmission( ); } + // IMPORTANT: Clean up activeExecutions BEFORE checking dependents + // Without this, dependents see this task as "Active: true" and skip execution + activeExecutions.delete(getTaskKey(projectId, sequence)); + // Trigger dependents await checkAndExecuteDependentTasks(projectId, sequence, task as Task, options); @@ -3788,6 +3825,22 @@ export function registerTaskExecutionHandlers(_mainWindow: BrowserWindow | null) } ); + /** + * Get recent execution history for a project + */ + ipcMain.handle('taskExecution:getRecent', async (_, projectId: number, limit: number = 50) => { + try { + const history = await taskHistoryRepository.findByProject(projectId, limit); + return { success: true, history }; + } catch (error) { + console.error('[TaskExecution] Failed to fetch recent history:', error); + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + console.log('Task execution IPC handlers registered'); // ======================================== diff --git a/electron/main/services/local-agent-scanner.ts b/electron/main/services/local-agent-scanner.ts new file mode 100644 index 0000000..f2f48aa --- /dev/null +++ b/electron/main/services/local-agent-scanner.ts @@ -0,0 +1,154 @@ +import path from 'path'; +import fs from 'fs/promises'; +import { app } from 'electron'; + +export interface LocalAgentContext { + source: 'claude' | 'gemini' | 'codex'; + goal?: string; + memory?: string; + guidelines?: string; +} + +export class LocalAgentScanner { + async scanFolder(folderPath: string): Promise { + if (!folderPath) return null; + + try { + // 1. Check for Gemini (Antigravity/CLI) artifacts + // Pattern: .gemini/antigravity/brain/*/task.md or similar + // Or simpler: look for task.md / implementation_plan.md in the root or .gemini folder + + const geminiContext = await this.scanGemini(folderPath); + if (geminiContext) return geminiContext; + + // 2. Check for Claude Code + // Pattern: .claude/config.json or similar (hypothetical) + const claudeContext = await this.scanClaude(folderPath); + if (claudeContext) return claudeContext; + + // 3. Check for Codex (hypothetical) + const codexContext = await this.scanCodex(folderPath); + if (codexContext) return codexContext; + } catch (error) { + console.error('Error scanning folder for local agents:', error); + } + + return null; + } + + private async scanGemini(rootPath: string): Promise { + // Check for task.md or implementation_plan.md + const taskPath = path.join(rootPath, 'task.md'); + const planPath = path.join(rootPath, 'implementation_plan.md'); + + let goal = ''; + let memory = ''; + + try { + const taskContent = await fs.readFile(taskPath, 'utf8'); + // Extract something useful? Maybe the first main header? + // Simple heuristic: First few lines might be the goal + goal += 'From task.md:\n' + taskContent.slice(0, 500) + '...\n'; + memory += taskContent; + } catch (e) { + /* ignore */ + } + + try { + const planContent = await fs.readFile(planPath, 'utf8'); + memory += '\n\nFrom implementation_plan.md:\n' + planContent; + } catch (e) { + /* ignore */ + } + + // Also check .gemini folder + // This is where Antigravity stores stuff usually: .gemini/antigravity/brain/... + // We might just look for ANY md files in .gemini/antigravity/brain + + if (goal || memory) { + return { + source: 'gemini', + goal: goal.trim() || 'Imported from Gemini Context', + memory: memory.trim(), + }; + } + return null; + } + + private async scanClaude(rootPath: string): Promise { + // Claude Code usually creates a .claude hidden directory + const claudeDir = path.join(rootPath, '.claude'); + try { + const stats = await fs.stat(claudeDir); + if (stats.isDirectory()) { + // Read some config or history? + // For now, just acknowledged it exists + return { + source: 'claude', + goal: 'Project managed by Claude Code', + memory: 'Detected .claude directory. Memory import pending specific format analysis.', + }; + } + } catch (e) { + /* ignore */ + } + return null; + } + + private async scanCodex(rootPath: string): Promise { + // Codex... maybe .codex? + const codexDir = path.join(rootPath, '.codex'); + try { + const stats = await fs.stat(codexDir); + if (stats.isDirectory()) { + return { + source: 'codex', + goal: 'Project managed by Codex', + memory: 'Detected .codex directory.', + }; + } + } catch (e) { + /* ignore */ + } + return null; + } + async scanProjectArtifacts(folderPath: string): Promise> { + if (!folderPath) return {}; + + const artifacts: Record = {}; + const candidates = [ + 'GEMINI.md', + 'PROJECT_RULES.md', + 'README.md', + 'CONTEXT.md', + 'SESSIONS.md', + 'task.md', + 'implementation_plan.md', + '.cursorrules', + '.windsurfrules', + ]; + + for (const filename of candidates) { + try { + const filePath = path.join(folderPath, filename); + const content = await fs.readFile(filePath, 'utf8'); + if (content && content.length > 0) { + artifacts[filename] = content; + } + } catch (e) { + // Ignore missing files + } + } + + // Also check .gemini/task.md if exists (common pattern) + try { + const taskPath = path.join(folderPath, '.gemini', 'task.md'); + const content = await fs.readFile(taskPath, 'utf8'); + artifacts['.gemini/task.md'] = content; + } catch (e) {} + + return artifacts; + } +} + +export const localAgentScanner = new LocalAgentScanner(); diff --git a/electron/main/services/local-agent-session.ts b/electron/main/services/local-agent-session.ts index 8c1ff56..a30ccd3 100644 --- a/electron/main/services/local-agent-session.ts +++ b/electron/main/services/local-agent-session.ts @@ -137,6 +137,8 @@ export class LocalAgentSession extends EventEmitter { private executionMode: 'persistent' | 'oneshot' = 'persistent'; private remoteThreadId: string | null = null; // Captured thread ID from agent private fullOutput: string = ''; // Capture full stdout for fallback + private accumulatedContent: string = ''; // Accumulate content from stream-json/other events + private lastErrorReason: string | null = null; // Capture specific error reason from agent events constructor( agentType: 'claude' | 'codex' | 'gemini-cli', @@ -165,6 +167,8 @@ export class LocalAgentSession extends EventEmitter { throw new Error('Session already started'); } + this.lastErrorReason = null; // Reset error reason + if (this.executionMode === 'oneshot') { console.log( `[LocalAgentSession] Starting ${this.agentType} session in one-shot mode (lazy spawn)` @@ -207,8 +211,21 @@ export class LocalAgentSession extends EventEmitter { throw new Error('Session not active'); } + this.lastErrorReason = null; // Reset error reason + if (this.status === 'running') { - throw new Error('Another message is being processed'); + // Safety check: if oneshot mode and process is missing/dead, force reset + if ( + this.executionMode === 'oneshot' && + (!this.process || this.process.killed || this.process.exitCode !== null) + ) { + console.warn( + `[LocalAgentSession] Session ${this.id} status was 'running' but process is dead/null. Resetting to idle.` + ); + this.status = 'idle'; + } else { + throw new Error('Another message is being processed'); + } } const timeout = options.timeout ?? 0; // 0 = unlimited @@ -253,10 +270,15 @@ export class LocalAgentSession extends EventEmitter { args.push('--dangerously-bypass-approvals-and-sandbox'); } else if (this.agentType === 'gemini-cli') { // Gemini CLI args - args.push('--json'); // Request JSON output + args.push('--output-format', 'stream-json'); // Request Streaming JSON output + args.push('--approval-mode', 'yolo'); // Auto-approve all tools if (options?.model) { args.push('--model', options.model); } + // Resume previous session if available + if (this.remoteThreadId) { + args.push('--resume', this.remoteThreadId); + } } else { // Default fallback (should vary by agent) args.push('exec'); @@ -408,8 +430,44 @@ export class LocalAgentSession extends EventEmitter { // Handle stderr this.process.stderr?.on('data', (data: Buffer) => { const text = data.toString(); + + // [Filter] Suppress known benign Node.js warnings from child process + if (text.includes('MaxListenersExceededWarning') && text.includes('AbortSignal')) { + return; + } + + // [Filter] Treat info messages printed to stderr as regular logs + if ( + text.includes('YOLO mode is enabled') || + text.includes('Loaded cached credentials') || + text.includes("Server 'sequential-thinking' supports tool updates") + ) { + console.log(`[LocalAgentSession] ${this.agentType} info:`, text); + return; + } + console.error(`[LocalAgentSession] ${this.agentType} stderr:`, text); - // Do NOT emit 'error' here as it crashes the app for non-fatal warnings + + // Fast-fail on 429 Rate Limit / Capacity errors for Gemini CLI + if (this.agentType === 'gemini-cli' || this.agentType === 'codex') { + if ( + text.includes('status 429') || + text.includes('RESOURCE_EXHAUSTED') || + text.includes('No capacity available') || + text.includes('429 Too Many Requests') + ) { + console.error( + `[LocalAgentSession] Detected 429 Rate Limit/Capacity Error for ${this.agentType}. Fast-failing.` + ); + this.lastErrorReason = + 'Quota exceeded or no capacity available (429). Please try again later or switch models.'; + + // Force kill the process immediately to stop internal tool retries + if (this.process && !this.process.killed) { + this.process.kill('SIGKILL'); + } + } + } }); // Handle process exit @@ -436,7 +494,12 @@ export class LocalAgentSession extends EventEmitter { // Only reject if not intentionally closing // Exit code 143 = 128 + 15 = SIGTERM (normal termination) if (this.currentReject && !this.isClosing) { - this.currentReject(new Error(`Process exited unexpectedly with code ${code}`)); + // Use captured error reason if available, otherwise generic message + const failureMessage = this.lastErrorReason + ? `Agent Failed: ${this.lastErrorReason}` + : `Process exited unexpectedly with code ${code}`; + + this.currentReject(new Error(failureMessage)); this.currentReject = null; this.currentResolve = null; this.currentOnChunk = null; @@ -542,39 +605,112 @@ export class LocalAgentSession extends EventEmitter { this.completeResponse({ ...message, finished: true }); } else if (message.type === 'error') { console.error('[LocalAgentSession] Codex Error Event:', message); + // Capture the error message + this.lastErrorReason = + typeof message.message === 'string' ? message.message : JSON.stringify(message); } return; } // Gemini CLI event handling if (this.agentType === 'gemini-cli') { - // Handle gemini-cli specific events - if ( - message.type === 'response' || - message.type === 'completion' || - message.text || - message.content - ) { - const textContent = String(message.text || message.content || ''); - if (textContent) { + const msg = message as any; + + // 1. Init event + if (msg.type === 'init') { + console.log(`[LocalAgentSession] Gemini CLI session started: ${msg.session_id}`); + if (msg.session_id) { + this.remoteThreadId = msg.session_id; + } + this.emit('started', this.getInfo()); + return; + } + + // 2. Message event (User or Assistant) + if (msg.type === 'message') { + const content = msg.content || ''; + + // Only emit assistant messages as 'message' events for the UI + if (msg.role === 'assistant') { const compatibleMsg = { type: 'assistant', message: { - content: [{ type: 'text', text: textContent }], + content: [{ type: 'text', text: content }], }, }; - this.captureTranscript(compatibleMsg); + + // Capture in transcript + this.captureTranscript({ + type: 'assistant', + message: { content: [{ type: 'text', text: content }] }, + }); + this.emit('message', compatibleMsg); - if (this.currentOnChunk) { - this.currentOnChunk(textContent); + if (this.currentOnChunk && msg.delta) { + // If delta is true, it might be a chunk, but the content seems to be full text in some contexts? + // Docs say "delta": true for simple chunks? + // The example "content": "Here are the files..." with delta: true implies a chunk. + this.currentOnChunk(content); + } else if (this.currentOnChunk && content) { + this.currentOnChunk(content); + } + + // Accumulate content for final result + if (content) { + this.accumulatedContent += content; } } + return; } - if (message.done || message.finished || message.type === 'completion') { - this.completeResponse({ ...message, finished: true }); + // 3. Tool Use event + if (msg.type === 'tool_use') { + console.log(`[LocalAgentSession] Gemini Tool Use: ${msg.tool_name}`); + // Capture in transcript for history + this.transcript.push({ + role: 'assistant', + type: 'tool_use', + timestamp: new Date(msg.timestamp || Date.now()), + metadata: { + tool_name: msg.tool_name, + tool_id: msg.tool_id, + parameters: msg.parameters, + }, + }); + return; + } + + // 4. Tool Result event + if (msg.type === 'tool_result') { + console.log(`[LocalAgentSession] Gemini Tool Result: ${msg.status}`); + this.transcript.push({ + role: 'user', // Tool results are inputs to the model + type: 'tool_result', + timestamp: new Date(msg.timestamp || Date.now()), + metadata: { + tool_id: msg.tool_id, + status: msg.status, + output: msg.output, + }, + }); + return; } + + // 5. Error event + if (msg.type === 'error') { + console.error(`[LocalAgentSession] Gemini Error:`, msg); + // Non-fatal errors, just log them or emit as transient error? + // Depending on severity, we might want to fail the turn. + return; + } + + // 6. Result event (Completion) + if (msg.type === 'result') { + this.completeResponse({ ...msg, finished: true }); + return; + } + return; } @@ -684,6 +820,7 @@ export class LocalAgentSession extends EventEmitter { message.content || message.text || message.result || + this.accumulatedContent || (this.executionMode === 'oneshot' && this.fullOutput ? this.fullOutput : '') || (this.transcript.length > 0 ? 'See transcript' : '') ), diff --git a/electron/main/services/terminal.ts b/electron/main/services/terminal.ts new file mode 100644 index 0000000..fe1dc57 --- /dev/null +++ b/electron/main/services/terminal.ts @@ -0,0 +1,335 @@ +import * as pty from 'node-pty'; +import { ipcMain } from 'electron'; +import { BrowserWindow } from 'electron'; +import fs from 'node:fs'; +import os from 'node:os'; +import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; +import { EventEmitter } from 'node:events'; + +// Fallback terminal implementation for when node-pty fails (binary mismatch) +class FallbackTerminal extends EventEmitter implements pty.IPty { + pid: number; + cols: number; + rows: number; + process: string; + handleFlowControl: boolean; + private _proc: ChildProcessWithoutNullStreams; + + constructor(file: string, args: string[] | string, opt: any) { + super(); + this.process = file; + this.cols = opt.cols || 80; + this.rows = opt.rows || 24; + this.handleFlowControl = false; + + // Force shell option to true for basic execution + this._proc = spawn(file, Array.isArray(args) ? args : [], { + cwd: opt.cwd, + env: opt.env, + shell: false, // Don't use shell wrapper, execute directly if 'file' is shell + }); + this.pid = this._proc.pid || 0; + + // Pipe output + this._proc.stdout.on('data', (data) => this.emit('data', data.toString())); + this._proc.stderr.on('data', (data) => this.emit('data', data.toString())); + this._proc.on('exit', (code) => this.emit('exit', code ?? 0)); + this._proc.on('error', (err) => { + console.error('[FallbackTerminal] Process error:', err); + this.emit('data', `\r\nError spawning process: ${err.message}\r\n`); + }); + } + + // IPty Implementation + get onData() { + return (listener: (data: string) => void) => { + this.on('data', listener); + return { dispose: () => this.off('data', listener) }; + }; + } + get onExit() { + return (listener: (e: { exitCode: number; signal?: number }) => void) => { + this.on('exit', (code) => listener({ exitCode: code })); + return { dispose: () => this.off('exit', listener) }; + }; + } + + write(data: string): void { + if (this._proc.stdin.writable) { + this._proc.stdin.write(data); + } + } + + resize(cols: number, rows: number): void { + this.cols = cols; + this.rows = rows; + // Basic spawn cannot handle resize signals effectively without pty + } + + kill(signal?: string): void { + this._proc.kill(signal as NodeJS.Signals); + } + + pause(): void {} + resume(): void {} + clear() {} +} + +interface TerminalSession { + pty: pty.IPty; + id: string; +} + +export class TerminalService { + private sessions: Map = new Map(); + private mainWindow: BrowserWindow | null = null; + + constructor(mainWindow: BrowserWindow | null) { + this.mainWindow = mainWindow; + } + + /** + * Create a new terminal session + */ + public createSession(id: string, cwd?: string, cols: number = 80, rows: number = 24): string { + let shell = ''; + + if (os.platform() === 'win32') { + shell = process.env.COMSPEC || 'powershell.exe'; + } else { + // MacOS / Linux + // 1. Try process.env.SHELL + if (process.env.SHELL && fs.existsSync(process.env.SHELL)) { + shell = process.env.SHELL; + } + // 2. Try standard paths + else if (fs.existsSync('/bin/zsh')) { + shell = '/bin/zsh'; + } else if (fs.existsSync('/usr/bin/zsh')) { + shell = '/usr/bin/zsh'; + } else if (fs.existsSync('/bin/bash')) { + shell = '/bin/bash'; + } else if (fs.existsSync('/usr/bin/bash')) { + shell = '/usr/bin/bash'; + } else { + // Fallback to simple 'sh' or 'zsh' if not found + shell = 'zsh'; + } + } + + // Validate CWD + let workingDirectory = cwd || os.homedir(); + try { + if (cwd && !fs.existsSync(cwd)) { + console.warn( + `[Terminal] Requested CWD does not exist: ${cwd}, falling back to homedir` + ); + workingDirectory = os.homedir(); + } + } catch (err) { + console.error('[Terminal] Failed to validate CWD:', err); + workingDirectory = os.homedir(); + } + + // Sanitize environment + const env: Record = {}; + + // Copy process.env but exclude ELECTRON_ variables + for (const key of Object.keys(process.env)) { + if (!key.startsWith('ELECTRON_')) { + env[key] = process.env[key] || ''; + } + } + + // Ensure PATH exists (critical for posix_spawnp) + if (!env.PATH) { + env.PATH = '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'; + console.warn('[Terminal] PATH was missing, using default:', env.PATH); + } + + // Fix encoding issues + if (!env.LANG) env.LANG = 'en_US.UTF-8'; + if (!env.LC_ALL) env.LC_ALL = 'en_US.UTF-8'; + + // Explicitly set SHELL env to match the binary we are running + env.SHELL = shell; + env.TERM = 'xterm-256color'; + + console.log(`[Terminal] Spawning ${shell} in ${workingDirectory}`); + + let ptyProcess: pty.IPty; + try { + ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols, + rows, + cwd: workingDirectory, + env: env as any, + }); + } catch (error: any) { + console.error( + `[Terminal] Failed to spawn shell '${shell}' in '${workingDirectory}'. Error: ${error.message}` + ); + + // AUTO-RETRY: Try /bin/bash or /bin/sh if primary shell failed + const fallbackShell = os.platform() === 'win32' ? 'cmd.exe' : '/bin/bash'; + + if (shell !== fallbackShell && fs.existsSync(fallbackShell)) { + try { + console.log(`[Terminal] Retrying with fallback PTY shell: ${fallbackShell}`); + env.SHELL = fallbackShell; + ptyProcess = pty.spawn(fallbackShell, [], { + name: 'xterm-256color', + cols, + rows, + cwd: workingDirectory, + env: env as any, + }); + // If successful, we update the session info + shell = fallbackShell; + } catch (retryError: any) { + console.error( + `[Terminal] Fallback PTY shell also failed: ${retryError.message}` + ); + return this.spawnFallback(id, shell, [], { + cols, + rows, + cwd: workingDirectory, + env, + }); + } + } else { + return this.spawnFallback(id, shell, [], { + cols, + rows, + cwd: workingDirectory, + env, + }); + } + } + + const session: TerminalSession = { + pty: ptyProcess, + id, + }; + + this.sessions.set(id, session); + + // Setup event listener for data + ptyProcess.onData((data) => { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(`terminal:data:${id}`, data); + } + }); + + ptyProcess.onExit(({ exitCode, signal }) => { + console.log( + `[Terminal] Session ${id} exited with code ${exitCode} and signal ${signal}` + ); + this.sessions.delete(id); + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(`terminal:exit:${id}`, { exitCode, signal }); + } + }); + + return id; + } + + private spawnFallback(id: string, file: string, args: string[], opt: any): string { + try { + console.warn('[Terminal] Using FallbackTerminal via child_process.spawn'); + const ptyProcess = new FallbackTerminal(file, args, opt); + + const session: TerminalSession = { + pty: ptyProcess, + id, + }; + this.sessions.set(id, session); + + // Re-bind listeners for fallback + ptyProcess.onData((data: string) => { + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(`terminal:data:${id}`, data); + } + }); + + ptyProcess.onExit(({ exitCode, signal }: { exitCode: number; signal?: number }) => { + console.log(`[Terminal] Fallback Session ${id} exited.`); + this.sessions.delete(id); + if (this.mainWindow && !this.mainWindow.isDestroyed()) { + this.mainWindow.webContents.send(`terminal:exit:${id}`, { exitCode, signal }); + } + }); + + return id; + } catch (fallbackError) { + console.error('[Terminal] Critical: Fallback failed completely', fallbackError); + throw fallbackError; + } + } + + /** + * Write data to a terminal session + */ + public write(id: string, data: string): void { + const session = this.sessions.get(id); + if (session) { + session.pty.write(data); + } + } + + /** + * Resize a terminal session + */ + public resize(id: string, cols: number, rows: number): void { + const session = this.sessions.get(id); + if (session) { + session.pty.resize(cols, rows); + } + } + + /** + * Kill a terminal session + */ + public kill(id: string): void { + const session = this.sessions.get(id); + if (session) { + session.pty.kill(); + this.sessions.delete(id); + } + } + + /** + * Kill all sessions + */ + public killAll(): void { + for (const id of this.sessions.keys()) { + this.kill(id); + } + } +} + +export function registerTerminalHandlers(mainWindow: BrowserWindow | null): TerminalService { + const terminalService = new TerminalService(mainWindow); + + ipcMain.handle( + 'terminal:create', + (_event, id: string, cwd?: string, cols?: number, rows?: number) => { + return terminalService.createSession(id, cwd, cols, rows); + } + ); + + ipcMain.handle('terminal:write', (_event, id: string, data: string) => { + terminalService.write(id, data); + }); + + ipcMain.handle('terminal:resize', (_event, id: string, cols: number, rows: number) => { + terminalService.resize(id, cols, rows); + }); + + ipcMain.handle('terminal:kill', (_event, id: string) => { + terminalService.kill(id); + }); + + return terminalService; +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index b5f9ea8..7b02b21 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -69,6 +69,12 @@ const projectsAPI = { ) => ipcRenderer.invoke('projects:import', data, userData), resetResults: (id: number) => ipcRenderer.invoke('projects:resetResults', id), + + syncLocalContext: (id: number) => ipcRenderer.invoke('projects:sync-local-context', id), + + scanArtifacts: (id: number) => ipcRenderer.invoke('projects:scan-artifacts', id), + + detectContext: (id: number) => ipcRenderer.invoke('projects:detect-context', id), }; // ======================================== @@ -136,6 +142,34 @@ const appAPI = { ipcRenderer.send('settings:update-notifications', config), }; +// ======================================== +// Terminal API +// ======================================== + +const terminalAPI = { + create: (id: string, cwd?: string, cols?: number, rows?: number) => + ipcRenderer.invoke('terminal:create', id, cwd, cols, rows), + + write: (id: string, data: string) => ipcRenderer.invoke('terminal:write', id, data), + + resize: (id: string, cols: number, rows: number) => + ipcRenderer.invoke('terminal:resize', id, cols, rows), + + kill: (id: string) => ipcRenderer.invoke('terminal:kill', id), + + onData: (id: string, callback: (data: string) => void) => { + const handler = (_event: any, data: string) => callback(data); + ipcRenderer.on(`terminal:data:${id}`, handler); + return () => ipcRenderer.removeListener(`terminal:data:${id}`, handler); + }, + + onExit: (id: string, callback: (endpoint: { exitCode: number; signal: number }) => void) => { + const handler = (_event: any, data: { exitCode: number; signal: number }) => callback(data); + ipcRenderer.on(`terminal:exit:${id}`, handler); + return () => ipcRenderer.removeListener(`terminal:exit:${id}`, handler); + }, +}; + // ======================================== // Window API // ======================================== @@ -570,7 +604,7 @@ const localAgentsAPI = { // Create a new agent session createSession: ( - agentType: 'claude' | 'codex', + agentType: 'claude' | 'codex' | 'gemini-cli', workingDirectory: string, sessionId?: string ): Promise => @@ -735,6 +769,9 @@ const taskExecutionAPI = { }> > => ipcRenderer.invoke('taskExecution:getAllActive'), + getRecent: (projectId: number, limit?: number): Promise => + ipcRenderer.invoke('taskExecution:getRecent', projectId, limit), + // NEEDS_APPROVAL state handlers requestApproval: ( projectId: number, @@ -1229,10 +1266,13 @@ const aiAPI = { ipcRenderer.invoke('ai:fetchModels', providerId, apiKey), getModelsFromCache: (providerId: string) => ipcRenderer.invoke('ai:getModelsFromCache', providerId), + saveModelsToCache: (providerId: string, models: any[]) => + ipcRenderer.invoke('ai:saveModelsToCache', providerId, models), saveProviderConfig: (providerId: string, config: any) => ipcRenderer.invoke('ai:saveProviderConfig', providerId, config), getProviderConfig: (providerId: string) => ipcRenderer.invoke('ai:getProviderConfig', providerId), + getEnvApiKey: (providerId: string) => ipcRenderer.invoke('ai:getEnvApiKey', providerId), }; // ======================================== @@ -1262,6 +1302,7 @@ const electronAPI = { auth: authAPI, http: httpAPI, ai: aiAPI, + terminal: terminalAPI, }; // Expose to renderer diff --git a/package.json b/package.json index 115f691..fea0392 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.26.0", "@google/genai": "^1.31.0", + "@headlessui/vue": "^1.7.23", "@iconify/vue": "^5.0.0", "@mistralai/mistralai": "^1.11.0", "@modelcontextprotocol/sdk": "^0.5.0", @@ -109,6 +110,7 @@ "nanoid": "^5.0.5", "node-cron": "^4.2.1", "node-fetch": "2", + "node-pty": "^1.1.0", "openai": "^4.28.4", "p-queue": "6.6.2", "pdf-parse": "1.1.1", @@ -132,6 +134,8 @@ "webidl-conversions": "^8.0.0", "whatwg-url": "^15.1.0", "xlsx": "^0.18.5", + "xterm": "^5.3.0", + "xterm-addon-fit": "^0.8.0", "y-indexeddb": "^9.0.12", "y-protocols": "^1.0.6", "y-websocket": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f71152..635f0c8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ dependencies: '@google/genai': specifier: ^1.31.0 version: 1.34.0(@modelcontextprotocol/sdk@0.5.0) + '@headlessui/vue': + specifier: ^1.7.23 + version: 1.7.23(vue@3.5.26) '@iconify/vue': specifier: ^5.0.0 version: 5.0.0(vue@3.5.26) @@ -190,6 +193,9 @@ dependencies: node-fetch: specifier: '2' version: 2.7.0 + node-pty: + specifier: ^1.1.0 + version: 1.1.0 openai: specifier: ^4.28.4 version: 4.104.0(zod@3.25.76) @@ -259,6 +265,12 @@ dependencies: xlsx: specifier: ^0.18.5 version: 0.18.5 + xterm: + specifier: ^5.3.0 + version: 5.3.0 + xterm-addon-fit: + specifier: ^0.8.0 + version: 0.8.0(xterm@5.3.0) y-indexeddb: specifier: ^9.0.12 version: 9.0.12(yjs@13.6.28) @@ -1821,6 +1833,16 @@ packages: - utf-8-validate dev: false + /@headlessui/vue@1.7.23(vue@3.5.26): + resolution: {integrity: sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==} + engines: {node: '>=10'} + peerDependencies: + vue: ^3.2.0 + dependencies: + '@tanstack/vue-virtual': 3.13.13(vue@3.5.26) + vue: 3.5.26(typescript@5.9.3) + dev: false + /@hono/node-server@1.19.7(hono@4.11.1): resolution: {integrity: sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==} engines: {node: '>=18.14.1'} @@ -2987,7 +3009,6 @@ packages: /@tanstack/virtual-core@3.13.13: resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==} - dev: true /@tanstack/vue-virtual@3.13.13(vue@3.5.26): resolution: {integrity: sha512-Cf2xIEE8nWAfsX0N5nihkPYMeQRT+pHt4NEkuP8rNCn6lVnLDiV8rC8IeIxbKmQC0yPnj4SIBLwXYVf86xxKTQ==} @@ -2996,7 +3017,6 @@ packages: dependencies: '@tanstack/virtual-core': 3.13.13 vue: 3.5.26(typescript@5.9.3) - dev: true /@tootallnate/once@2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} @@ -8724,6 +8744,10 @@ packages: resolution: {integrity: sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==} dev: true + /node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + dev: false + /node-api-version@0.1.4: resolution: {integrity: sha512-KGXihXdUChwJAOHO53bv9/vXcLmdUsZ6jIptbvYvkpKfth+r7jw44JkVxQFA3kX5nQjzjmGu1uAu/xNNLNlI5g==} dependencies: @@ -8805,6 +8829,13 @@ packages: - supports-color dev: true + /node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + requiresBuild: true + dependencies: + node-addon-api: 7.1.1 + dev: false + /node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} dev: true @@ -11326,6 +11357,20 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + /xterm-addon-fit@0.8.0(xterm@5.3.0): + resolution: {integrity: sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==} + deprecated: This package is now deprecated. Move to @xterm/addon-fit instead. + peerDependencies: + xterm: ^5.0.0 + dependencies: + xterm: 5.3.0 + dev: false + + /xterm@5.3.0: + resolution: {integrity: sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==} + deprecated: This package is now deprecated. Move to @xterm/xterm instead. + dev: false + /y-indexeddb@9.0.12(yjs@13.6.28): resolution: {integrity: sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} diff --git a/src/components/board/cards/AiTaskCard.vue b/src/components/board/cards/AiTaskCard.vue index d8b628c..3bf4660 100644 --- a/src/components/board/cards/AiTaskCard.vue +++ b/src/components/board/cards/AiTaskCard.vue @@ -1,16 +1,14 @@ diff --git a/src/components/common/TerminalComponent.vue b/src/components/common/TerminalComponent.vue new file mode 100644 index 0000000..a879bee --- /dev/null +++ b/src/components/common/TerminalComponent.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/src/components/common/UnifiedAISelector.vue b/src/components/common/UnifiedAISelector.vue index 78faae4..4c5e517 100644 --- a/src/components/common/UnifiedAISelector.vue +++ b/src/components/common/UnifiedAISelector.vue @@ -56,6 +56,7 @@ const availableLocalAgents = computed(() => { version?: string; }[] = [ { id: 'claude', name: 'Claude Code', icon: '🤖', installed: false }, + { id: 'gemini-cli', name: 'Gemini CLI', icon: '✨', installed: false }, { id: 'codex', name: 'OpenAI Codex', icon: '💻', installed: false }, ]; diff --git a/src/components/common/UpdateModal.vue b/src/components/common/UpdateModal.vue index 1eeff98..951dcfb 100644 --- a/src/components/common/UpdateModal.vue +++ b/src/components/common/UpdateModal.vue @@ -48,18 +48,20 @@ function preventClose(event: Event) { >
-
-
-
+
+
+
-
+
+ +

{{ viewLabel }}

diff --git a/src/components/project/ProjectInfoModal.vue b/src/components/project/ProjectInfoModal.vue deleted file mode 100644 index e490ba5..0000000 --- a/src/components/project/ProjectInfoModal.vue +++ /dev/null @@ -1,511 +0,0 @@ - - - - - diff --git a/src/components/project/ProjectInfoPanel.vue b/src/components/project/ProjectInfoPanel.vue index de615ea..72b065c 100644 --- a/src/components/project/ProjectInfoPanel.vue +++ b/src/components/project/ProjectInfoPanel.vue @@ -10,8 +10,9 @@ * - Cost and token usage statistics */ -import { computed, ref, watch } from 'vue'; +import { computed, ref, watch, nextTick } from 'vue'; import { useI18n } from 'vue-i18n'; +import ProjectMemoryPanel from './ProjectMemoryPanel.vue'; import { marked } from 'marked'; import type { MCPConfig, Project } from '@core/types/database'; @@ -37,6 +38,7 @@ function isLocalAgentProvider(provider: string | null): { const localAgentMap: Record = { 'claude-code': 'claude', codex: 'codex', + 'gemini-cli': 'gemini-cli', }; const agentType = localAgentMap[provider]; @@ -58,26 +60,37 @@ function isLocalAgentProvider(provider: string | null): { const props = defineProps<{ project: Project; + show?: boolean; // Controls visibility compact?: boolean; }>(); const emit = defineEmits<{ + (e: 'close'): void; (e: 'edit'): void; (e: 'open-output'): void; - (e: 'update-guidelines', guidelines: string): void; - (e: 'update-base-folder', folder: string): void; - ( - e: 'update-ai-settings', - settings: { aiProvider: string | null; aiModel: string | null } - ): void; - (e: 'update-output-type', type: string | null): void; - ( - e: 'update-auto-review-settings', - settings: { aiProvider: string | null; aiModel: string | null } - ): void; - (e: 'update-mcp-config', config: MCPConfig | null): void; }>(); +// Panel state +const activeTab = ref<'info' | 'context'>('info'); + +function handleClose() { + emit('close'); +} + +// Watch for show prop to handle body scroll or ESC key if needed +watch( + () => props.show, + (show, _, onCleanup) => { + if (show) { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleClose(); + }; + window.addEventListener('keydown', handleEscape); + onCleanup(() => window.removeEventListener('keydown', handleEscape)); + } + } +); + // ======================================== // State // ======================================== @@ -142,11 +155,16 @@ const aiProviderDisplay = computed(() => { const providerId = isEditingAI.value ? editedAIProvider.value : effectiveAI.value.provider; // Check if it's a local agent - if (providerId && ['claude-code', 'codex'].includes(providerId)) { + if (providerId && isLocalAgentProvider(providerId).isLocal) { + const localIcons: Record = { + 'claude-code': '🤖', + codex: '💻', + 'gemini-cli': '✨', + }; return { name: getAssistantLabel(providerId), color: 'text-gray-400', - icon: getAssistantIcon(providerId), + icon: localIcons[providerId] || '💻', }; } @@ -358,7 +376,7 @@ const autoReviewProviderDisplay = computed(() => { effectiveAutoReview.value.provider; // Check if it's a local agent - if (providerId && ['claude-code', 'codex'].includes(providerId)) { + if (providerId && isLocalAgentProvider(providerId).isLocal) { return { name: getAssistantLabel(providerId), color: 'text-gray-400', @@ -411,11 +429,18 @@ watch( // Methods // ======================================== +const titleInputRef = ref(null); + function startEditMetadata(): void { editedTitle.value = props.project.title; // Assuming project has emoji field, cast to any if TS complains or update Project type if possible editedEmoji.value = (props.project as any).emoji || ''; isEditingMetadata.value = true; + + // Focus title input + nextTick(() => { + titleInputRef.value?.focus(); + }); } function cancelEditMetadata(): void { @@ -463,9 +488,18 @@ function cancelEditGuidelines(): void { editedGuidelines.value = ''; } -function saveGuidelines(): void { - emit('update-guidelines', editedGuidelines.value); - isEditingGuidelines.value = false; +async function saveGuidelines(): Promise { + try { + const projectStore = useProjectStore(); + await projectStore.updateProject(props.project.id, { + aiGuidelines: editedGuidelines.value || null, + }); + isEditingGuidelines.value = false; + // Optimization: update local effective value display immediately if needed, + // but store reactivity should handle it via props.project watcher/computed + } catch (error) { + console.error('Failed to update guidelines:', error); + } } function copyGuidelines(): void { @@ -474,15 +508,593 @@ function copyGuidelines(): void { } } -function saveBaseFolder(): void { - emit('update-base-folder', editedBaseFolder.value); +async function saveBaseFolder(): Promise { + try { + const projectStore = useProjectStore(); + await projectStore.updateProject(props.project.id, { + baseDevFolder: editedBaseFolder.value || null, + }); + + // Auto-detect context after folder assignment + if (editedBaseFolder.value) { + await detectExistingContext(); + } + } catch (error) { + console.error('Failed to update base folder:', error); + } } async function pickBaseFolder(): Promise { const dir = await (window as any)?.electron?.fs?.selectDirectory?.(); if (dir) { editedBaseFolder.value = dir; - saveBaseFolder(); + await saveBaseFolder(); + } +} + +// ======================================== +// Context Recovery Feature +// ======================================== + +interface DetectedContext { + hasContext: boolean; + // Detection flags + hasClaudeMd?: boolean; + hasGeminiDir?: boolean; + hasCodexDir?: boolean; + hasGit?: boolean; + hasGeminiMd?: boolean; + hasAgentsMd?: boolean; // Codex AGENTS.md + hasTaskMd?: boolean; // Gemini task.md + hasPlanMd?: boolean; // implementation_plan.md + hasReadme?: boolean; + // Content + claudeMdContent?: string | null; + geminiMdContent?: string | null; + agentsMdContent?: string | null; // Codex + taskMdContent?: string | null; + planMdContent?: string | null; + geminiTaskMdContent?: string | null; + recentCommits?: string[]; + error?: string; +} + +const showContextRecoveryDialog = ref(false); +const detectedContext = ref(null); + +async function detectExistingContext(): Promise { + if (!props.project.id || !props.project.baseDevFolder) return; + + try { + const result = await (window as any).electron.projects.detectContext(props.project.id); + + if (result.hasContext) { + detectedContext.value = result; + showContextRecoveryDialog.value = true; + } + } catch (error) { + console.error('Failed to detect context:', error); + } +} + +async function handleImportGuidelines(): Promise { + const ctx = detectedContext.value; + if (!ctx) return; + + // Combine all available guidelines content + const guidelinesParts: string[] = []; + + // Claude Code + if (ctx.claudeMdContent) { + guidelinesParts.push('# Claude Code Guidelines\n\n' + ctx.claudeMdContent); + } + + // Gemini CLI + if (ctx.geminiMdContent) { + guidelinesParts.push('# Gemini CLI Guidelines\n\n' + ctx.geminiMdContent); + } + + // Codex (OpenAI) + if (ctx.agentsMdContent) { + guidelinesParts.push('# Codex Guidelines\n\n' + ctx.agentsMdContent); + } + + if (guidelinesParts.length === 0) return; + + const combinedGuidelines = guidelinesParts.join('\n\n---\n\n'); + + const projectStore = useProjectStore(); + await projectStore.updateProject(props.project.id, { + aiGuidelines: combinedGuidelines, + }); + showContextRecoveryDialog.value = false; +} + +// Helper to check if any agent context source has importable guidelines +const hasImportableGuidelines = computed(() => { + const ctx = detectedContext.value; + if (!ctx) return false; + return !!(ctx.claudeMdContent || ctx.geminiMdContent || ctx.agentsMdContent); +}); + +async function handleRunAnalysis(): Promise { + showContextRecoveryDialog.value = false; + // Use existing analyzeProjectWithAI with git history included + await analyzeProjectWithAI(true); +} + +function handleSkipRecovery(): void { + showContextRecoveryDialog.value = false; + detectedContext.value = null; +} + +const isAnalyzing = ref(false); + +import { aiServiceManager } from '../../services/workflow/AIServiceManager'; +import { useLocalAgentExecution } from '../../composables/useLocalAgentExecution'; + +// Initialize local agent execution for analysis +const localAgentExecution = useLocalAgentExecution(); + +async function analyzeProjectWithAI(includeGitHistory: boolean = false) { + if (!props.project.id || !props.project.baseDevFolder) return; + + isAnalyzing.value = true; + try { + // 1. Scan artifacts via Main process + const result = await (window as any).electron.projects.scanArtifacts(props.project.id); + + if (!result.success || !result.artifacts || Object.keys(result.artifacts).length === 0) { + console.warn('No artifacts found to analyze'); + alert('No project artifacts (GEMINI.md, README.md, etc.) found in the base folder.'); + return; + } + + const artifacts = result.artifacts; + let artifactsText = Object.entries(artifacts) + .map(([name, content]) => `--- File: ${name} ---\n${content}\n`) + .join('\n'); + + // Include git history if in recovery mode + if (includeGitHistory && detectedContext.value?.recentCommits?.length) { + artifactsText += `\n--- Recent Git Commits ---\n${detectedContext.value.recentCommits.join('\n')}\n`; + } + + // 2. Construct Prompt for AI - use enhanced prompt in recovery mode + const systemPrompt = includeGitHistory + ? ` +You are recovering context from a previous development session. +Analyze the project artifacts AND git history to understand: + +1. Project Goal (concise, high-level objective) +2. AI Guidelines (rules, style guides, constraints from CLAUDE.md, GEMINI.md, etc.) +3. Progress Summary (what has been done so far based on git history and artifacts) +4. Suggested Next Step (what appears to be the logical next step) + +Output purely in JSON format: +{ + "goal": "string", + "guidelines": "string (markdown)", + "context_summary": "string (markdown) - include progress summary", + "suggested_next_step": "string (actionable recommendation)" +} +` + : ` +You are a specialized context management agent responsible for maintaining coherent state across multiple agent interactions and sessions. +Your role is critical for complex, long-running projects. + +Extract the following from the provided project files: +1. Project Goal (concise, high-level objective) +2. AI Guidelines (rules, style guides, constraints) +3. Context Summary (architecture, key decisions, status) + +Output purely in JSON format: +{ + "goal": "string", + "guidelines": "string (markdown)", + "context_summary": "string (markdown)" +} +`; + const userPrompt = `Here are the project artifacts found in ${props.project.baseDevFolder}:\n\n${artifactsText}`; + const fullPrompt = systemPrompt + '\n\n' + userPrompt; + + // 3. Determine if using local agent or cloud API + const localAgentProviders = ['gemini-cli', 'claude-code', 'codex', 'claude']; + const configuredProvider = props.project.aiProvider || ''; + const isLocalAgent = localAgentProviders.includes(configuredProvider); + + let responseContent: string | null = null; + + if (isLocalAgent) { + // Use local agent via session + console.log('[ProjectInfoPanel] Using local agent for analysis:', configuredProvider); + + // Map provider name to local agent type + const agentTypeMap: Record = { + 'gemini-cli': 'gemini-cli', + 'claude-code': 'claude', + claude: 'claude', + codex: 'codex', + }; + const agentType = agentTypeMap[configuredProvider] || 'gemini-cli'; + + // Create session and send message + const session = await localAgentExecution.createSession( + agentType, + props.project.baseDevFolder + ); + if (!session) { + throw new Error( + `Failed to start ${agentType} session. Make sure ${agentType} is installed and accessible.` + ); + } + + try { + const response = await localAgentExecution.sendMessage(fullPrompt, { + timeout: 120000, // 2 minute timeout for analysis + }); + + if (response?.success) { + responseContent = response.content; + } else { + throw new Error(response?.error || 'Local agent returned no response'); + } + } finally { + // Always close the session after single-use analysis + await localAgentExecution.closeSession(); + } + } else { + // Use cloud API + const provider = configuredProvider || 'google'; + const getDefaultModel = (prov: string): string => { + switch (prov) { + case 'openai': + return 'gpt-4o-mini'; + case 'anthropic': + return 'claude-sonnet-4-20250514'; + case 'google': + return 'gemini-2.0-flash'; + default: + return 'gemini-2.0-flash'; + } + }; + const model = props.project.aiModel || getDefaultModel(provider); + + console.log('[ProjectInfoPanel] Using cloud API for analysis:', { provider, model }); + + const response = await aiServiceManager.generateContent(fullPrompt, { + provider, + model, + temperature: 0.2, + }); + responseContent = response?.content || null; + } + + // 4. Parse and apply results + if (responseContent) { + let parsed: any = {}; + try { + // Try to clean markdown code blocks if present + const cleanJson = responseContent.replace(/```json\n|```\n|```/g, '').trim(); + parsed = JSON.parse(cleanJson); + } catch (e) { + console.warn('Failed to parse AI response as JSON, using raw text'); + } + + const updateData: any = {}; + if (parsed.goal) updateData.goal = parsed.goal; + if (parsed.guidelines) updateData.aiGuidelines = parsed.guidelines; + + if (parsed.context_summary) { + const existingDesc = props.project.description || ''; + if (!existingDesc.includes('[AI Analysis]')) { + updateData.description = + existingDesc + `\n\n[AI Analysis]\n${parsed.context_summary}`; + } + } + + if (Object.keys(updateData).length > 0) { + const projectStore = useProjectStore(); + await projectStore.updateProject(props.project.id, updateData); + console.log('Project updated with AI analysis'); + } + } + } catch (err) { + console.error('Failed to analyze project:', err); + alert('Failed to analyze project: ' + (err instanceof Error ? err.message : String(err))); + } finally { + isAnalyzing.value = false; + } +} + +// ======================================== +// AI-Powered Task Breakdown +// ======================================== + +const isGeneratingTasks = ref(false); + +interface GeneratedTask { + order: number; + title: string; + description: string; + prompt: string; + expectedOutputFormat: string; + estimatedMinutes: number; +} + +async function generateTasksFromGoal() { + if (!props.project.id || !props.project.goal) { + alert('프로젝트 Goal이 설정되어 있지 않습니다. Goal을 먼저 설정해 주세요.'); + return; + } + + if (!props.project.baseDevFolder) { + alert('프로젝트 개발 폴더가 설정되어 있지 않습니다. 폴더를 먼저 지정해 주세요.'); + return; + } + + isGeneratingTasks.value = true; + try { + // 1. Scan current project state to understand what's already implemented + const scanResult = await (window as any).electron.projects.scanArtifacts(props.project.id); + + let currentStateContext = ''; + if (scanResult.success && scanResult.artifacts) { + currentStateContext = Object.entries(scanResult.artifacts) + .map(([name, content]) => `--- File: ${name} ---\n${content}\n`) + .join('\n'); + } + + // Also get git history if available + let gitContext = ''; + if (detectedContext.value?.recentCommits?.length) { + gitContext = `\n--- Recent Git Commits ---\n${detectedContext.value.recentCommits.join('\n')}\n`; + } + + // 2. Build the prompt for task breakdown with current state awareness + const systemPrompt = `You are an expert project planner and AI coding assistant. +Your task is to analyze a project goal, understand the CURRENT STATE of the project, and create tasks ONLY for work that still needs to be done. + +CRITICAL: You will be given the current project files and recent git history. +- Analyze what has ALREADY been implemented +- Identify what is MISSING or INCOMPLETE +- Generate tasks ONLY for the remaining work + +For each task, you must provide: +1. A clear, concise title +2. A detailed description of what needs to be done +3. A well-crafted prompt that an AI coding agent can use to complete the task +4. Expected output format (code, markdown, json, html, css, etc.) +5. Estimated time in minutes + +The prompt for each task should: +- Be specific and actionable +- Reference any existing code or files that need to be modified +- Include context from previous tasks +- Reference the project guidelines if relevant +- Be self-contained enough for an AI to execute +- Mention specific file paths when modifying existing code + +Output JSON format: +{ + "analysis": { + "implemented": ["list of features/components already done"], + "remaining": ["list of features/components still needed"] + }, + "tasks": [ + { + "order": 1, + "title": "Task title", + "description": "Detailed description", + "prompt": "Full, detailed prompt for AI execution including all necessary context and requirements", + "expectedOutputFormat": "code|markdown|json|html|css|text", + "estimatedMinutes": 30 + } + ] +} + +Important rules: +- SKIP any work that is already completed based on the current project state +- Create tasks ONLY for remaining/incomplete work +- Each task should be atomic and achievable in one AI session +- Tasks should follow logical dependency order +- Later tasks can reference outputs from earlier tasks +- Be specific about which files to create or modify +- The prompts should be comprehensive and include all necessary details`; + + const userPrompt = `Project Goal: ${props.project.goal} + +${props.project.aiGuidelines ? `Project Guidelines:\n${props.project.aiGuidelines}` : ''} + +${props.project.description ? `Project Description:\n${props.project.description}` : ''} + +Working Directory: ${props.project.baseDevFolder} + +=== CURRENT PROJECT STATE === +The following files and content represent what has ALREADY been implemented: + +${currentStateContext || '(No project files found - this appears to be a new project)'} +${gitContext} + +=== TASK GENERATION REQUEST === +Based on the goal and the CURRENT STATE above: +1. First analyze what has been implemented vs what remains +2. Then generate tasks ONLY for the remaining work +3. Skip anything that is already complete`; + + const fullPrompt = systemPrompt + '\n\n' + userPrompt; + + // 2. Determine provider (same logic as analyzeProjectWithAI) + const localAgentProviders = ['gemini-cli', 'claude-code', 'codex', 'claude']; + const configuredProvider = props.project.aiProvider || ''; + const isLocalAgent = localAgentProviders.includes(configuredProvider); + + let responseContent: string | null = null; + + if (isLocalAgent) { + // Use local agent via session + console.log( + '[ProjectInfoPanel] Using local agent for task generation:', + configuredProvider + ); + + const agentTypeMap: Record = { + 'gemini-cli': 'gemini-cli', + 'claude-code': 'claude', + claude: 'claude', + codex: 'codex', + }; + const agentType = agentTypeMap[configuredProvider] || 'gemini-cli'; + + const session = await localAgentExecution.createSession( + agentType, + props.project.baseDevFolder || '.' + ); + if (!session) { + throw new Error(`Failed to start ${agentType} session.`); + } + + try { + const response = await localAgentExecution.sendMessage(fullPrompt, { + timeout: 180000, // 3 minute timeout for task generation + }); + + if (response?.success) { + responseContent = response.content; + } else { + throw new Error(response?.error || 'Local agent returned no response'); + } + } finally { + await localAgentExecution.closeSession(); + } + } else { + // Use cloud API + const provider = configuredProvider || 'google'; + const getDefaultModel = (prov: string): string => { + switch (prov) { + case 'openai': + return 'gpt-4o-mini'; + case 'anthropic': + return 'claude-sonnet-4-20250514'; + case 'google': + return 'gemini-2.0-flash'; + default: + return 'gemini-2.0-flash'; + } + }; + const model = props.project.aiModel || getDefaultModel(provider); + + console.log('[ProjectInfoPanel] Using cloud API for task generation:', { + provider, + model, + }); + + const response = await aiServiceManager.generateContent(fullPrompt, { + provider, + model, + temperature: 0.3, + }); + responseContent = response?.content || null; + } + + // 3. Parse the response + if (!responseContent) { + throw new Error('AI did not return any response'); + } + + let parsedTasks: GeneratedTask[] = []; + let analysis: { implemented?: string[]; remaining?: string[] } = {}; + try { + const cleanJson = responseContent.replace(/```json\n|```\n|```/g, '').trim(); + const parsed = JSON.parse(cleanJson); + parsedTasks = parsed.tasks || []; + analysis = parsed.analysis || {}; + } catch (e) { + console.error('Failed to parse task breakdown response:', e); + throw new Error('AI 응답을 파싱하는데 실패했습니다. 다시 시도해 주세요.'); + } + + if (parsedTasks.length === 0) { + // Check if everything is already implemented + if (analysis.implemented?.length && !analysis.remaining?.length) { + alert( + '✅ 프로젝트가 이미 완료된 것으로 보입니다!\n\n구현 완료된 항목:\n• ' + + analysis.implemented.join('\n• ') + ); + return; + } + throw new Error('AI가 태스크를 생성하지 않았습니다.'); + } + + // 4. Create tasks with dependencies + const createdTaskIds: number[] = []; + + for (let i = 0; i < parsedTasks.length; i++) { + const task = parsedTasks[i]; + if (!task) continue; // Skip undefined tasks + + // Build dependencies (all previous tasks) + const dependencies = createdTaskIds.slice(); // Copy of all created task IDs so far + + // Create the task + const newTask = await (window as any).electron.tasks.create({ + projectId: props.project.id, + title: task.title, + description: task.description, + generatedPrompt: task.prompt, + expectedOutputFormat: task.expectedOutputFormat || 'markdown', + estimatedMinutes: task.estimatedMinutes || 30, + status: 'todo', + priority: 'medium', + taskType: 'ai', + // Inherit project AI settings + aiProvider: props.project.aiProvider || null, + aiModel: props.project.aiModel || null, + // Set execution order + executionOrder: i + 1, + // Dependencies (previous task) + dependencies: + dependencies.length > 0 ? [dependencies[dependencies.length - 1]] : [], + // Auto-approve for intermediate tasks, review for final task + autoApprove: i < parsedTasks.length - 1, + autoReview: i === parsedTasks.length - 1, + }); + + if (newTask?.projectSequence) { + createdTaskIds.push(newTask.projectSequence); + } + + console.log( + `[ProjectInfoPanel] Created task ${i + 1}/${parsedTasks.length}: ${task.title}` + ); + } + + // Build success message with analysis + let successMessage = `✅ ${parsedTasks.length}개의 태스크가 생성되었습니다!\n\n`; + + if (analysis.implemented?.length) { + successMessage += `📦 이미 구현됨:\n• ${analysis.implemented.slice(0, 3).join('\n• ')}`; + if (analysis.implemented.length > 3) { + successMessage += `\n ... 외 ${analysis.implemented.length - 3}개`; + } + successMessage += '\n\n'; + } + + if (analysis.remaining?.length) { + successMessage += `🎯 남은 작업:\n• ${analysis.remaining.slice(0, 5).join('\n• ')}`; + if (analysis.remaining.length > 5) { + successMessage += `\n ... 외 ${analysis.remaining.length - 5}개`; + } + } + + successMessage += '\n\n태스크들은 순서대로 의존성이 설정되어 있습니다.'; + + alert(successMessage); + } catch (err) { + console.error('Failed to generate tasks from goal:', err); + alert('태스크 생성 실패: ' + (err instanceof Error ? err.message : String(err))); + } finally { + isGeneratingTasks.value = false; } } @@ -528,31 +1140,31 @@ async function saveAISettings(): Promise { const agentMap: Record = { claude: 'claude-code', codex: 'codex', + 'gemini-cli': 'gemini-cli', }; providerToSave = (agentMap[editedLocalAgent.value] || editedLocalAgent.value) as AIProvider; // For local agents, the model might be redundant or same as provider ID, but let's keep it clean modelToSave = null; } - // Emit settings update - emit('update-ai-settings', { - aiProvider: providerToSave, - aiModel: modelToSave, - }); + try { + const projectStore = useProjectStore(); + await projectStore.updateProject(props.project.id, { + aiProvider: providerToSave, + aiModel: modelToSave, + }); - // If project was synced with Claude, mark as manually overridden - if (wasClaudeCodeSynced) { - try { - const projectStore = useProjectStore(); + // If project was synced with Claude, mark as manually overridden + if (wasClaudeCodeSynced) { const overrideUpdate = projectClaudeSyncService.markAsOverridden(props.project as any); await projectStore.updateProject(props.project.id, overrideUpdate as any); console.log('[ProjectInfoPanel] Marked project settings as manually overridden'); - } catch (error) { - console.error('[ProjectInfoPanel] Failed to mark as overridden:', error); } - } - isEditingAI.value = false; + isEditingAI.value = false; + } catch (error) { + console.error('Failed to update AI settings:', error); + } } // MCP 관련 함수 @@ -569,9 +1181,33 @@ function cancelEditMCP(): void { editedMCPConfig.value = null; } -function saveMCPSettings(): void { - emit('update-mcp-config', editedMCPConfig.value); - isEditingMCP.value = false; +async function saveMCPSettings(): Promise { + try { + const projectStore = useProjectStore(); + // Deep clone to ensure no proxies are passed + const configToSave = editedMCPConfig.value + ? JSON.parse(JSON.stringify(editedMCPConfig.value)) + : {}; + + // Auto-configure filesystem MCP if enabled and baseDevFolder is set + if (configToSave['filesystem'] && props.project.baseDevFolder) { + configToSave['filesystem'].config = { + ...(configToSave['filesystem'].config || {}), + args: [ + '-y', + '@modelcontextprotocol/server-filesystem', + props.project.baseDevFolder, + ], + }; + } + + await projectStore.updateProject(props.project.id, { + mcpConfig: configToSave, + }); + isEditingMCP.value = false; + } catch (error) { + console.error('Failed to update MCP settings:', error); + } } // Output Type Methods @@ -585,9 +1221,16 @@ function cancelEditOutputType(): void { editedOutputType.value = null; } -function saveOutputType(): void { - emit('update-output-type', editedOutputType.value); - isEditingOutputType.value = false; +async function saveOutputType(): Promise { + try { + const projectStore = useProjectStore(); + await projectStore.updateProject(props.project.id, { + outputType: editedOutputType.value || null, + }); + isEditingOutputType.value = false; + } catch (error) { + console.error('Failed to update output type:', error); + } } // Goal Methods @@ -644,12 +1287,21 @@ function cancelEditAutoReview(): void { editedAutoReviewLocalAgent.value = null; } -function saveAutoReviewSettings(): void { - emit('update-auto-review-settings', { - aiProvider: editedAutoReviewProvider.value, - aiModel: editedAutoReviewModel.value, - }); - isEditingAutoReview.value = false; +async function saveAutoReviewSettings(): Promise { + try { + const projectStore = useProjectStore(); + const currentMetadata = (props.project as any).metadata || {}; + await projectStore.updateProject(props.project.id, { + metadata: { + ...currentMetadata, + autoReviewProvider: editedAutoReviewProvider.value, + autoReviewModel: editedAutoReviewModel.value, + }, + }); + isEditingAutoReview.value = false; + } catch (error) { + console.error('Failed to update auto-review settings:', error); + } } // Helper functions for displaying assistant types @@ -660,6 +1312,8 @@ function getAssistantIcon(type: string): string { 'claude-code': 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm88,104a87.62,87.62,0,0,1-6.4,32.94l-44.7-27.49a15.92,15.92,0,0,0-6.24-2.23l-22.82-3.08a16.11,16.11,0,0,0-16,7.86h-8.72l-3.8-7.86a15.91,15.91,0,0,0-11.89-8.42l-22.26-3a16.09,16.09,0,0,0-13.38,4.93L40,132.19A88,88,0,0,1,128,40a87.53,87.53,0,0,1,15.87,1.46L159.3,56a16,16,0,0,0,12.26,5.61h19.41A88.22,88.22,0,0,1,216,128Z', codex: 'M229.66,90.34l-64-64a8,8,0,0,0-11.32,0l-64,64a8,8,0,0,0,11.32,11.32L152,51.31V96a8,8,0,0,0,16,0V51.31l50.34,50.35a8,8,0,0,0,11.32-11.32ZM208,144a40,40,0,1,0-40,40A40,40,0,0,0,208,144Zm-64,0a24,24,0,1,1,24,24A24,24,0,0,1,144,144ZM88,104A40,40,0,1,0,48,144,40,40,0,0,0,88,104ZM64,144a24,24,0,1,1,24-24A24,24,0,0,1,64,144Zm176,72a40,40,0,1,0-40,40A40,40,0,0,0,240,216Zm-64,0a24,24,0,1,1,24,24A24,24,0,0,1,176,216Z', + 'gemini-cli': + 'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Z', }; return (icons[type] || icons.git) as string; } @@ -668,6 +1322,7 @@ function getAssistantLabel(type: string): string { const labels: Record = { git: 'Git', 'claude-code': 'Claude Code', + 'gemini-cli': 'Gemini CLI', codex: 'Codex', cursor: 'Cursor', @@ -680,860 +1335,1279 @@ function getAssistantLabel(type: string): string { + + + diff --git a/src/components/task/InputTaskForm.vue b/src/components/task/InputTaskForm.vue index 49c689a..512f98d 100644 --- a/src/components/task/InputTaskForm.vue +++ b/src/components/task/InputTaskForm.vue @@ -1,5 +1,6 @@ + + + diff --git a/src/components/task/TaskEditModal.vue b/src/components/task/TaskEditModal.vue index 80543e6..67a2798 100644 --- a/src/components/task/TaskEditModal.vue +++ b/src/components/task/TaskEditModal.vue @@ -122,11 +122,11 @@ watch( } if (localRepoTypes.includes('claude-code')) { - autoProvider = 'anthropic'; + autoProvider = 'claude-code'; autoModel = currentProject.aiModel || 'claude-3-5-sonnet-20240620'; - - autoProvider = 'google'; - autoModel = currentProject.aiModel || 'gemini-2.5-pro'; + } else if (localRepoTypes.includes('gemini-cli')) { + autoProvider = 'gemini-cli'; + autoModel = currentProject.aiModel || 'gemini-2.0-flash-thinking-exp-1219'; } else if (localRepoTypes.includes('codex')) { autoProvider = 'openai'; autoModel = currentProject.aiModel || 'gpt-4o'; @@ -174,6 +174,7 @@ const aiProviderOptions: Array<{ { value: 'openai', label: 'GPT', icon: '🟢', description: 'OpenAI GPT-4' }, { value: 'google', label: 'Gemini', icon: '🔵', description: 'Google Gemini' }, { value: 'claude-code', label: 'Claude Code', icon: '💻', description: 'Claude Code Agent' }, + { value: 'gemini-cli', label: 'Gemini CLI', icon: '✨', description: 'Gemini CLI Agent' }, { value: 'codex', label: 'Codex', icon: '⚡', description: 'Codex AI Agent' }, { value: 'local', label: 'Local', icon: '🏠', description: 'Local LLM' }, @@ -203,7 +204,9 @@ const providerModelOptions: Record = { value: 'llama3-8b-8192', label: 'Llama 3 8B' }, ], 'claude-code': [{ value: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet' }], - + 'gemini-cli': [ + { value: 'gemini-2.0-flash-thinking-exp-1219', label: 'Gemini 2.0 Flash Thinking' }, + ], codex: [{ value: 'gpt-4o', label: 'GPT-4o (Codex)' }], local: [ { value: 'llama3', label: 'Llama 3' }, @@ -370,10 +373,12 @@ async function estimateTimeWithAI() {
-
+
-
+