From 9fe5b485f8765b1c720637c3ffc159f466ccc666 Mon Sep 17 00:00:00 2001 From: Ramiro Rivera Date: Thu, 1 Jan 2026 13:33:40 +0100 Subject: [PATCH 01/91] feat: support ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN for custom endpoints - apps/server/src/providers/claude-provider.ts Add ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN to the environment variable allowlist, enabling use of LLM gateways (LiteLLM, Helicone) and Anthropic- compatible providers (GLM 4.7, Minimax M2.1, etc.). Closes #338 --- apps/server/src/providers/claude-provider.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 33494535..ee3204f0 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -19,6 +19,8 @@ import type { // Only these vars are passed - nothing else from process.env leaks through. const ALLOWED_ENV_VARS = [ 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', 'PATH', 'HOME', 'SHELL', From d2f64f10fff11f796a64c15295f8f00e23b68ebe Mon Sep 17 00:00:00 2001 From: Ramiro Rivera Date: Thu, 1 Jan 2026 13:43:12 +0100 Subject: [PATCH 02/91] test: add tests for ANTHROPIC_BASE_URL and ANTHROPIC_AUTH_TOKEN passthrough - apps/server/tests/unit/providers/claude-provider.test.ts Verify custom endpoint environment variables are passed to the SDK. --- .../unit/providers/claude-provider.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/apps/server/tests/unit/providers/claude-provider.test.ts b/apps/server/tests/unit/providers/claude-provider.test.ts index 3dbd9982..e9afa1c0 100644 --- a/apps/server/tests/unit/providers/claude-provider.test.ts +++ b/apps/server/tests/unit/providers/claude-provider.test.ts @@ -12,6 +12,8 @@ describe('claude-provider.ts', () => { vi.clearAllMocks(); provider = new ClaudeProvider(); delete process.env.ANTHROPIC_API_KEY; + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; }); describe('getName', () => { @@ -286,6 +288,93 @@ describe('claude-provider.ts', () => { }); }); + describe('environment variable passthrough', () => { + afterEach(() => { + delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_AUTH_TOKEN; + }); + + it('should pass ANTHROPIC_BASE_URL to SDK env', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://custom.example.com/v1'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://custom.example.com/v1', + }), + }), + }); + }); + + it('should pass ANTHROPIC_AUTH_TOKEN to SDK env', async () => { + process.env.ANTHROPIC_AUTH_TOKEN = 'custom-auth-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_AUTH_TOKEN: 'custom-auth-token', + }), + }), + }); + }); + + it('should pass both custom endpoint vars together', async () => { + process.env.ANTHROPIC_BASE_URL = 'https://gateway.example.com'; + process.env.ANTHROPIC_AUTH_TOKEN = 'gateway-token'; + + vi.mocked(sdk.query).mockReturnValue( + (async function* () { + yield { type: 'text', text: 'test' }; + })() + ); + + const generator = provider.executeQuery({ + prompt: 'Test', + cwd: '/test', + }); + + await collectAsyncGenerator(generator); + + expect(sdk.query).toHaveBeenCalledWith({ + prompt: 'Test', + options: expect.objectContaining({ + env: expect.objectContaining({ + ANTHROPIC_BASE_URL: 'https://gateway.example.com', + ANTHROPIC_AUTH_TOKEN: 'gateway-token', + }), + }), + }); + }); + }); + describe('getAvailableModels', () => { it('should return 4 Claude models', () => { const models = provider.getAvailableModels(); From a9403651d464fed075f0f6dc6f47972f039e7f46 Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Fri, 2 Jan 2026 00:58:32 +0100 Subject: [PATCH 03/91] fix: handle pipeline resume edge cases and improve robustness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes several edge cases when resuming features stuck in pipeline steps: - Detect if feature is stuck in a pipeline step during resume - Handle case where context file is missing (restart from beginning) - Handle case where pipeline step no longer exists in config - Add dedicated resumePipelineFeature() method for pipeline-specific resume logic - Add detectPipelineStatus() to extract and validate pipeline step information - Add resumeFromPipelineStep() to resume from a specific pipeline step index - Update board view to check context availability for features with pipeline status Edge cases handled: 1. No context file → restart entire pipeline from beginning 2. Step no longer exists in config → complete feature without pipeline 3. Valid step exists → resume from the crashed step 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/services/auto-mode-service.ts | 351 ++++++++++++++++++ .../board-view/hooks/use-board-effects.ts | 5 +- package-lock.json | 91 ++--- 3 files changed, 388 insertions(+), 59 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index ad1b3efa..9269d5a1 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -700,6 +700,25 @@ Complete the pipeline step instructions above. Review the previous work and appl throw new Error('already running'); } + // Load feature to check status + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + // Check if feature is stuck in a pipeline step + const pipelineInfo = await this.detectPipelineStatus( + projectPath, + featureId, + feature.status || '' + ); + + if (pipelineInfo.isPipeline) { + // Feature stuck in pipeline - use pipeline resume + return this.resumePipelineFeature(projectPath, featureId, useWorktrees, pipelineInfo); + } + + // Normal resume flow for non-pipeline features // Check if context exists in .automaker directory const featureDir = getFeatureDir(projectPath, featureId); const contextPath = path.join(featureDir, 'agent-output.md'); @@ -724,6 +743,239 @@ Complete the pipeline step instructions above. Review the previous work and appl return this.executeFeature(projectPath, featureId, useWorktrees, false); } + /** + * Resume a feature that crashed during pipeline execution + * Handles edge cases: no context, missing step, deleted pipeline step + * @param pipelineInfo - Information about the pipeline status from detectPipelineStatus() + */ + private async resumePipelineFeature( + projectPath: string, + featureId: string, + useWorktrees: boolean, + pipelineInfo: { + isPipeline: boolean; + stepId: string | null; + stepIndex: number; + totalSteps: number; + step: PipelineStep | null; + config: PipelineConfig | null; + } + ): Promise { + console.log( + `[AutoMode] Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}` + ); + + // Check for context file + const featureDir = getFeatureDir(projectPath, featureId); + const contextPath = path.join(featureDir, 'agent-output.md'); + + let hasContext = false; + try { + await secureFs.access(contextPath); + hasContext = true; + } catch { + // No context + } + + // Edge Case 1: No context file - restart entire pipeline from beginning + if (!hasContext) { + console.warn( + `[AutoMode] No context found for pipeline feature ${featureId}, restarting from beginning` + ); + + // Reset status to in_progress and start fresh + await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); + + // Remove temporary entry + this.runningFeatures.delete(featureId); + + return this.executeFeature(projectPath, featureId, useWorktrees, false); + } + + // Edge Case 2: Step no longer exists in pipeline config + if (pipelineInfo.stepIndex === -1) { + console.warn( + `[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline` + ); + + const feature = await this.loadFeature(projectPath, featureId); + const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; + + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + passes: true, + message: + 'Pipeline step no longer exists - feature completed without remaining pipeline steps', + projectPath, + }); + + // Remove temporary entry + this.runningFeatures.delete(featureId); + return; + } + + // Normal case: Valid pipeline step exists, has context + // Resume from the stuck step (re-execute the step that crashed) + if (!pipelineInfo.config) { + throw new Error('Pipeline config is null but stepIndex is valid - this should not happen'); + } + + return this.resumeFromPipelineStep( + projectPath, + featureId, + useWorktrees, + pipelineInfo.stepIndex, + pipelineInfo.config + ); + } + + /** + * Resume pipeline execution from a specific step index + * Re-executes the step that crashed, then continues with remaining steps + * @param pipelineConfig - Pipeline config passed from detectPipelineStatus to avoid re-reading + */ + private async resumeFromPipelineStep( + projectPath: string, + featureId: string, + useWorktrees: boolean, + startFromStepIndex: number, + pipelineConfig: PipelineConfig + ): Promise { + // Load feature and validate + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) { + throw new Error(`Feature ${featureId} not found`); + } + + const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); + + // Validate step index + if (startFromStepIndex < 0 || startFromStepIndex >= sortedSteps.length) { + throw new Error(`Invalid step index: ${startFromStepIndex}`); + } + + // Get steps to execute (from startFromStepIndex onwards) + const stepsToExecute = sortedSteps.slice(startFromStepIndex); + + console.log( + `[AutoMode] Resuming pipeline for feature ${featureId} from step ${startFromStepIndex + 1}/${sortedSteps.length}` + ); + + // Add to running features immediately + const abortController = new AbortController(); + this.runningFeatures.set(featureId, { + featureId, + projectPath, + worktreePath: null, // Will be set below + branchName: feature.branchName ?? null, + abortController, + isAutoMode: false, + startTime: Date.now(), + }); + + try { + // Validate project path + validateWorkingDirectory(projectPath); + + // Derive workDir from feature.branchName + let worktreePath: string | null = null; + const branchName = feature.branchName; + + if (useWorktrees && branchName) { + worktreePath = await this.findExistingWorktreeForBranch(projectPath, branchName); + if (worktreePath) { + console.log(`[AutoMode] Using worktree for branch "${branchName}": ${worktreePath}`); + } else { + console.warn( + `[AutoMode] Worktree for branch "${branchName}" not found, using project path` + ); + } + } + + const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); + validateWorkingDirectory(workDir); + + // Update running feature with worktree info + const runningFeature = this.runningFeatures.get(featureId); + if (runningFeature) { + runningFeature.worktreePath = worktreePath; + runningFeature.branchName = branchName ?? null; + } + + // Emit resume event + this.emitAutoModeEvent('auto_mode_feature_start', { + featureId, + projectPath, + feature: { + id: featureId, + title: feature.title || 'Resuming Pipeline', + description: feature.description, + }, + }); + + this.emitAutoModeEvent('auto_mode_progress', { + featureId, + content: `Resuming from pipeline step ${startFromStepIndex + 1}/${sortedSteps.length}`, + projectPath, + }); + + // Load autoLoadClaudeMd setting + const autoLoadClaudeMd = await getAutoLoadClaudeMdSetting( + projectPath, + this.settingsService, + '[AutoMode]' + ); + + // Execute remaining pipeline steps (starting from crashed step) + await this.executePipelineSteps( + projectPath, + featureId, + feature, + stepsToExecute, + workDir, + abortController, + autoLoadClaudeMd + ); + + // Determine final status + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; + await this.updateFeatureStatus(projectPath, featureId, finalStatus); + + console.log('[AutoMode] Pipeline resume completed successfully'); + + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + passes: true, + message: 'Pipeline resumed and completed successfully', + projectPath, + }); + } catch (error) { + const errorInfo = classifyError(error); + + if (errorInfo.isAbort) { + this.emitAutoModeEvent('auto_mode_feature_complete', { + featureId, + passes: false, + message: 'Pipeline resume stopped by user', + projectPath, + }); + } else { + console.error(`[AutoMode] Pipeline resume failed for feature ${featureId}:`, error); + await this.updateFeatureStatus(projectPath, featureId, 'backlog'); + this.emitAutoModeEvent('auto_mode_error', { + featureId, + error: errorInfo.message, + errorType: errorInfo.type, + projectPath, + }); + } + } finally { + this.runningFeatures.delete(featureId); + } + } + /** * Follow up on a feature with additional instructions */ @@ -2504,6 +2756,105 @@ Review the previous work and continue the implementation. If the feature appears }); } + /** + * Detect if a feature is stuck in a pipeline step and extract step info + * @returns Pipeline information including step details and config + */ + private async detectPipelineStatus( + projectPath: string, + featureId: string, + currentStatus: string + ): Promise<{ + isPipeline: boolean; + stepId: string | null; + stepIndex: number; + totalSteps: number; + step: PipelineStep | null; + config: PipelineConfig | null; + }> { + // Check if status is pipeline format using PipelineService + const isPipeline = pipelineService.isPipelineStatus(currentStatus); + + if (!isPipeline) { + return { + isPipeline: false, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + } + + // Extract step ID using PipelineService + const stepId = pipelineService.getStepIdFromStatus(currentStatus); + + if (!stepId) { + console.warn( + `[AutoMode] Feature ${featureId} has invalid pipeline status format: ${currentStatus}` + ); + return { + isPipeline: true, + stepId: null, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + } + + // Load pipeline config + const config = await pipelineService.getPipelineConfig(projectPath); + + if (!config || config.steps.length === 0) { + // Pipeline config doesn't exist or empty - feature stuck with invalid pipeline status + console.warn( + `[AutoMode] Feature ${featureId} has pipeline status but no pipeline config exists` + ); + return { + isPipeline: true, + stepId, + stepIndex: -1, + totalSteps: 0, + step: null, + config: null, + }; + } + + // Find the step using PipelineService + const step = await pipelineService.getStep(projectPath, stepId); + const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order); + const stepIndex = sortedSteps.findIndex((s) => s.id === stepId); + + if (stepIndex === -1 || !step) { + // Step not found in current config - step was deleted/changed + console.warn( + `[AutoMode] Feature ${featureId} stuck in step ${stepId} which no longer exists in pipeline config` + ); + return { + isPipeline: true, + stepId, + stepIndex: -1, + totalSteps: sortedSteps.length, + step: null, + config, + }; + } + + console.log( + `[AutoMode] Detected pipeline status for feature ${featureId}: step ${stepIndex + 1}/${sortedSteps.length} (${step.name})` + ); + + return { + isPipeline: true, + stepId, + stepIndex, + totalSteps: sortedSteps.length, + step, + config, + }; + } + /** * Build a focused prompt for executing a single task. * Each task gets minimal context to keep the agent focused. diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts index fbe76344..6dde9c08 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-effects.ts @@ -123,7 +123,10 @@ export function useBoardEffects({ const checkAllContexts = async () => { const featuresWithPotentialContext = features.filter( (f) => - f.status === 'in_progress' || f.status === 'waiting_approval' || f.status === 'verified' + f.status === 'in_progress' || + f.status === 'waiting_approval' || + f.status === 'verified' || + (typeof f.status === 'string' && f.status.startsWith('pipeline_')) ); const contextChecks = await Promise.all( featuresWithPotentialContext.map(async (f) => ({ diff --git a/package-lock.json b/package-lock.json index d8190a03..81d55416 100644 --- a/package-lock.json +++ b/package-lock.json @@ -455,6 +455,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1038,6 +1039,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz", "integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1080,6 +1082,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -1900,7 +1903,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1922,7 +1924,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -1939,7 +1940,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -1954,7 +1954,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2722,7 +2721,6 @@ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=18" } @@ -2847,7 +2845,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2864,7 +2861,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2881,7 +2877,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -2990,7 +2985,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3013,7 +3007,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3036,7 +3029,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3122,7 +3114,6 @@ ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/runtime": "^1.7.0" }, @@ -3145,7 +3136,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3165,7 +3155,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -3565,8 +3554,7 @@ "version": "16.0.10", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz", "integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { "version": "16.0.10", @@ -3580,7 +3568,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3597,7 +3584,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3614,7 +3600,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3631,7 +3616,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3648,7 +3632,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3665,7 +3648,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3682,7 +3664,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3699,7 +3680,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 10" } @@ -3790,6 +3770,7 @@ "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright": "1.57.0" }, @@ -5230,7 +5211,6 @@ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -5564,6 +5544,7 @@ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz", "integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==", "license": "MIT", + "peer": true, "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", @@ -5990,6 +5971,7 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -6132,6 +6114,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6142,6 +6125,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -6247,6 +6231,7 @@ "integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.0", "@typescript-eslint/types": "8.50.0", @@ -6740,7 +6725,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@xyflow/react": { "version": "12.10.0", @@ -6838,6 +6824,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6898,6 +6885,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -7496,6 +7484,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8027,8 +8016,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cliui": { "version": "8.0.1", @@ -8333,8 +8321,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-env": { "version": "10.1.0", @@ -8431,6 +8418,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -8732,6 +8720,7 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -9058,7 +9047,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -9079,7 +9067,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -9330,6 +9317,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -9644,6 +9632,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -11311,7 +11300,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -11373,7 +11361,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -13801,7 +13788,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", @@ -13818,7 +13804,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13836,7 +13821,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -14025,6 +14009,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14034,6 +14019,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14392,7 +14378,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -14581,6 +14566,7 @@ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz", "integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==", "license": "MIT", + "peer": true, "engines": { "node": ">=10" } @@ -14629,7 +14615,6 @@ "hasInstallScript": true, "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", @@ -14680,7 +14665,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14703,7 +14687,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14726,7 +14709,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14743,7 +14725,6 @@ "os": [ "darwin" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14760,7 +14741,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14777,7 +14757,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14794,7 +14773,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14811,7 +14789,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14828,7 +14805,6 @@ "os": [ "linux" ], - "peer": true, "funding": { "url": "https://opencollective.com/libvips" } @@ -14845,7 +14821,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14868,7 +14843,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14891,7 +14865,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14914,7 +14887,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14937,7 +14909,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -14960,7 +14931,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, @@ -15429,7 +15399,6 @@ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", "license": "MIT", - "peer": true, "dependencies": { "client-only": "0.0.1" }, @@ -15599,7 +15568,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15663,7 +15631,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -15761,6 +15728,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15965,6 +15933,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16336,6 +16305,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -16425,7 +16395,8 @@ "resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz", "integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", @@ -16451,6 +16422,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16493,6 +16465,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -16750,6 +16723,7 @@ "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -16818,6 +16792,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From c587947de6a18d7662c80495bed1717a5ca8f453 Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Fri, 2 Jan 2026 12:42:04 +0100 Subject: [PATCH 04/91] refactor: optimize feature loading in pipeline resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduce redundant file reads by loading the feature object once and passing it down the call chain instead of reloading it multiple times. Changes: - Pass feature object to resumePipelineFeature() instead of featureId - Pass feature object to resumeFromPipelineStep() instead of featureId - Remove redundant loadFeature() calls from these methods - Add FeatureStatusWithPipeline import for type safety This improves performance by eliminating unnecessary file I/O operations and makes the data flow clearer. Co-authored-by: gemini-code-assist bot 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/services/auto-mode-service.ts | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 9269d5a1..cab06923 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -10,7 +10,13 @@ */ import { ProviderFactory } from '../providers/provider-factory.js'; -import type { ExecuteOptions, Feature, PipelineConfig, PipelineStep } from '@automaker/types'; +import type { + ExecuteOptions, + Feature, + FeatureStatusWithPipeline, + PipelineConfig, + PipelineStep, +} from '@automaker/types'; import { buildPromptWithImages, isAbortError, @@ -710,12 +716,12 @@ Complete the pipeline step instructions above. Review the previous work and appl const pipelineInfo = await this.detectPipelineStatus( projectPath, featureId, - feature.status || '' + (feature.status || '') as FeatureStatusWithPipeline ); if (pipelineInfo.isPipeline) { // Feature stuck in pipeline - use pipeline resume - return this.resumePipelineFeature(projectPath, featureId, useWorktrees, pipelineInfo); + return this.resumePipelineFeature(projectPath, feature, useWorktrees, pipelineInfo); } // Normal resume flow for non-pipeline features @@ -746,11 +752,12 @@ Complete the pipeline step instructions above. Review the previous work and appl /** * Resume a feature that crashed during pipeline execution * Handles edge cases: no context, missing step, deleted pipeline step + * @param feature - The feature object (already loaded to avoid redundant reads) * @param pipelineInfo - Information about the pipeline status from detectPipelineStatus() */ private async resumePipelineFeature( projectPath: string, - featureId: string, + feature: Feature, useWorktrees: boolean, pipelineInfo: { isPipeline: boolean; @@ -761,6 +768,7 @@ Complete the pipeline step instructions above. Review the previous work and appl config: PipelineConfig | null; } ): Promise { + const featureId = feature.id; console.log( `[AutoMode] Resuming feature ${featureId} from pipeline step ${pipelineInfo.stepId}` ); @@ -798,8 +806,7 @@ Complete the pipeline step instructions above. Review the previous work and appl `[AutoMode] Step ${pipelineInfo.stepId} no longer exists in pipeline, completing feature without pipeline` ); - const feature = await this.loadFeature(projectPath, featureId); - const finalStatus = feature?.skipTests ? 'waiting_approval' : 'verified'; + const finalStatus = feature.skipTests ? 'waiting_approval' : 'verified'; await this.updateFeatureStatus(projectPath, featureId, finalStatus); @@ -824,7 +831,7 @@ Complete the pipeline step instructions above. Review the previous work and appl return this.resumeFromPipelineStep( projectPath, - featureId, + feature, useWorktrees, pipelineInfo.stepIndex, pipelineInfo.config @@ -834,20 +841,17 @@ Complete the pipeline step instructions above. Review the previous work and appl /** * Resume pipeline execution from a specific step index * Re-executes the step that crashed, then continues with remaining steps + * @param feature - The feature object (already loaded to avoid redundant reads) * @param pipelineConfig - Pipeline config passed from detectPipelineStatus to avoid re-reading */ private async resumeFromPipelineStep( projectPath: string, - featureId: string, + feature: Feature, useWorktrees: boolean, startFromStepIndex: number, pipelineConfig: PipelineConfig ): Promise { - // Load feature and validate - const feature = await this.loadFeature(projectPath, featureId); - if (!feature) { - throw new Error(`Feature ${featureId} not found`); - } + const featureId = feature.id; const sortedSteps = [...pipelineConfig.steps].sort((a, b) => a.order - b.order); @@ -2763,7 +2767,7 @@ Review the previous work and continue the implementation. If the feature appears private async detectPipelineStatus( projectPath: string, featureId: string, - currentStatus: string + currentStatus: FeatureStatusWithPipeline ): Promise<{ isPipeline: boolean; stepId: string | null; From 3ff9658723cccc589713de24930b94ba8893bbbd Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Fri, 2 Jan 2026 12:44:19 +0100 Subject: [PATCH 05/91] refactor: remove unnecessary runningFeatures.delete() calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove confusing and unnecessary delete calls from resumeFeature() and resumePipelineFeature() methods. These were leftovers from a previous implementation where temporary entries were added to runningFeatures. The resumeFeature() method already ensures the feature is not running at the start (via has() check that throws if already running), so these delete calls serve no purpose and only add confusion about state management. Removed delete calls from: - resumeFeature() non-pipeline flow (line 748) - resumePipelineFeature() no-context case (line 798) - resumePipelineFeature() step-not-found case (line 822) Co-authored-by: gemini-code-assist bot 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/services/auto-mode-service.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index cab06923..805cf642 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -744,8 +744,6 @@ Complete the pipeline step instructions above. Review the previous work and appl } // No context, start fresh - executeFeature will handle adding to runningFeatures - // Remove the temporary entry we added - this.runningFeatures.delete(featureId); return this.executeFeature(projectPath, featureId, useWorktrees, false); } @@ -794,9 +792,6 @@ Complete the pipeline step instructions above. Review the previous work and appl // Reset status to in_progress and start fresh await this.updateFeatureStatus(projectPath, featureId, 'in_progress'); - // Remove temporary entry - this.runningFeatures.delete(featureId); - return this.executeFeature(projectPath, featureId, useWorktrees, false); } @@ -818,8 +813,6 @@ Complete the pipeline step instructions above. Review the previous work and appl projectPath, }); - // Remove temporary entry - this.runningFeatures.delete(featureId); return; } From 7a2a3ef50065e215b37d95cdda47e66acf7ed445 Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Fri, 2 Jan 2026 13:02:43 +0100 Subject: [PATCH 06/91] refactor: create PipelineStatusInfo interface to eliminate type duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define a dedicated PipelineStatusInfo interface and use it consistently in both resumePipelineFeature() parameter and detectPipelineStatus() return type. This eliminates duplicate inline type definitions and improves maintainability by ensuring both locations always stay in sync. Any future changes to the pipeline status structure only need to be made in one place. Changes: - Add PipelineStatusInfo interface definition - Replace inline type in resumePipelineFeature() with PipelineStatusInfo - Replace inline return type in detectPipelineStatus() with PipelineStatusInfo Co-authored-by: gemini-code-assist bot 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/services/auto-mode-service.ts | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 805cf642..12b2620e 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -74,6 +74,18 @@ interface PlanSpec { tasks?: ParsedTask[]; } +/** + * Information about pipeline status when resuming a feature + */ +interface PipelineStatusInfo { + isPipeline: boolean; + stepId: string | null; + stepIndex: number; + totalSteps: number; + step: PipelineStep | null; + config: PipelineConfig | null; +} + /** * Parse tasks from generated spec content * Looks for the ```tasks code block and extracts task lines @@ -757,14 +769,7 @@ Complete the pipeline step instructions above. Review the previous work and appl projectPath: string, feature: Feature, useWorktrees: boolean, - pipelineInfo: { - isPipeline: boolean; - stepId: string | null; - stepIndex: number; - totalSteps: number; - step: PipelineStep | null; - config: PipelineConfig | null; - } + pipelineInfo: PipelineStatusInfo ): Promise { const featureId = feature.id; console.log( @@ -2761,14 +2766,7 @@ Review the previous work and continue the implementation. If the feature appears projectPath: string, featureId: string, currentStatus: FeatureStatusWithPipeline - ): Promise<{ - isPipeline: boolean; - stepId: string | null; - stepIndex: number; - totalSteps: number; - step: PipelineStep | null; - config: PipelineConfig | null; - }> { + ): Promise { // Check if status is pipeline format using PipelineService const isPipeline = pipelineService.isPipelineStatus(currentStatus); From 2d309f6833d75e226487edcfa8c733f1910ceb3a Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Fri, 2 Jan 2026 13:04:01 +0100 Subject: [PATCH 07/91] perf: eliminate redundant file read in detectPipelineStatus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unnecessary call to pipelineService.getStep() which was causing a redundant file read of pipeline.json. The config is already loaded at line 2807, so we can find the step directly from the in-memory config. Changes: - Sort config.steps first - Find stepIndex using findIndex() - Get step directly from sortedSteps[stepIndex] instead of calling getStep() - Simplify null check to only check !step instead of stepIndex === -1 || !step This optimization reduces file I/O operations and improves performance when resuming pipeline features. Co-authored-by: gemini-code-assist bot 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- apps/server/src/services/auto-mode-service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 12b2620e..5460b02c 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -2816,12 +2816,12 @@ Review the previous work and continue the implementation. If the feature appears }; } - // Find the step using PipelineService - const step = await pipelineService.getStep(projectPath, stepId); + // Find the step directly from config (already loaded, avoid redundant file read) const sortedSteps = [...config.steps].sort((a, b) => a.order - b.order); const stepIndex = sortedSteps.findIndex((s) => s.id === stepId); + const step = stepIndex === -1 ? null : sortedSteps[stepIndex]; - if (stepIndex === -1 || !step) { + if (!step) { // Step not found in current config - step was deleted/changed console.warn( `[AutoMode] Feature ${featureId} stuck in step ${stepId} which no longer exists in pipeline config` From 2a87d55519a3ae4ba12d70e282e0bbd4a06313db Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Fri, 2 Jan 2026 13:22:11 +0100 Subject: [PATCH 08/91] fix: show Resume button for features stuck in pipeline steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable the Resume button to appear for features with pipeline status (e.g., 'pipeline_step_xyz') in addition to 'in_progress' status. Previously, features that crashed during pipeline execution would show a 'testing' status badge but no Resume button, making it impossible to resume them from the UI. Changes: - Update card-actions.tsx condition to include pipeline_ status check - Resume button now shows for both in_progress and pipeline_step_* statuses - Maintains all existing behavior for other feature states This fixes the UX issue where users could see a feature was stuck in a pipeline step but had no way to resume it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../components/kanban-card/card-actions.tsx | 166 +++++++++--------- 1 file changed, 84 insertions(+), 82 deletions(-) diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index df0d8707..762d5a66 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -109,88 +109,90 @@ export function CardActions({ )} )} - {!isCurrentAutoTask && feature.status === 'in_progress' && ( - <> - {/* Approve Plan button - shows when plan is generated and waiting for approval */} - {feature.planSpec?.status === 'generated' && onApprovePlan && ( - - )} - {feature.skipTests && onManualVerify ? ( - - ) : hasContext && onResume ? ( - - ) : onVerify ? ( - - ) : null} - {onViewOutput && !feature.skipTests && ( - - )} - - )} + {!isCurrentAutoTask && + (feature.status === 'in_progress' || + (typeof feature.status === 'string' && feature.status.startsWith('pipeline_'))) && ( + <> + {/* Approve Plan button - shows when plan is generated and waiting for approval */} + {feature.planSpec?.status === 'generated' && onApprovePlan && ( + + )} + {feature.skipTests && onManualVerify ? ( + + ) : hasContext && onResume ? ( + + ) : onVerify ? ( + + ) : null} + {onViewOutput && !feature.skipTests && ( + + )} + + )} {!isCurrentAutoTask && feature.status === 'verified' && ( <> {/* Logs button */} From 71e03c2a13762ad5f8d46b1818037702a94d1fb5 Mon Sep 17 00:00:00 2001 From: Stephan Rieche Date: Fri, 2 Jan 2026 13:55:05 +0100 Subject: [PATCH 09/91] fix: restore Verify button fallback with honest labeling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-add the onVerify fallback for in_progress/pipeline features without context, but fix the misleading UX issue where the button said 'Resume' but executed verification (tests/build). Changes: - Restore onVerify fallback as 3rd option after skipTests Verify and Resume - Change button label from 'Resume' to 'Verify' (honest!) - Change icon from PlayCircle to CheckCircle2 (matches action) - Keep same green styling for consistency This makes sense because if a feature is in_progress but has no context, it likely completed execution but the context was deleted. User should be able to verify it (run tests/build) rather than having no action available. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../components/kanban-card/card-actions.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx index 9cfcaee7..9753ba7c 100644 --- a/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx +++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-actions.tsx @@ -160,6 +160,21 @@ export function CardActions({ Resume + ) : onVerify ? ( + ) : null} {onViewOutput && !feature.skipTests && ( diff --git a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx index 38381836..081add89 100644 --- a/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx +++ b/apps/ui/src/components/layout/sidebar/components/sidebar-footer.tsx @@ -235,7 +235,7 @@ export function SidebarFooter({ {sidebarOpen && (

- Select or create a project above + Select or create a project above

) : currentProject ? ( @@ -137,7 +137,7 @@ export function SidebarNavigation({ {item.shortcut && sidebarOpen && !item.count && ( Date: Sun, 11 Jan 2026 20:15:23 -0800 Subject: [PATCH 15/91] Fix model selector on mobile --- IMPLEMENTATION_PLAN.md | 203 +++++++++++++ .../model-defaults/phase-model-selector.tsx | 285 ++++++++++++++++++ apps/ui/src/hooks/use-media-query.ts | 50 +++ 3 files changed, 538 insertions(+) create mode 100644 IMPLEMENTATION_PLAN.md create mode 100644 apps/ui/src/hooks/use-media-query.ts diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..3ca605f4 --- /dev/null +++ b/IMPLEMENTATION_PLAN.md @@ -0,0 +1,203 @@ +# Implementation Plan: Fix Mobile Model Selection + +## Problem Statement +Users cannot change the model when creating a new task on mobile devices. The model selector uses fixed-width Radix UI Popovers with nested secondary popovers that extend to the right, causing the interface to go off-screen on mobile devices (typically 375-428px width). + +## Root Cause Analysis + +### Current Implementation Issues: +1. **Fixed Widths**: Main popover is 320px, secondary popovers are 220px +2. **Horizontal Nesting**: Secondary popovers (thinking levels, reasoning effort, cursor variants) position to the right of the main popover +3. **No Collision Handling**: Radix Popover doesn't have sufficient collision padding configured +4. **No Mobile-Specific UI**: Same component used for all screen sizes + +### Affected Files: +- `/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx` - Core implementation +- `/apps/ui/src/components/views/agent-view/shared/agent-model-selector.tsx` - Wrapper for agent view +- `/apps/ui/src/components/views/agent-view/input-area/input-controls.tsx` - Usage location + +## Proposed Solution: Responsive Popover with Mobile Optimization + +### Approach: Add Responsive Width & Collision Handling + +**Rationale**: Minimal changes, maximum compatibility, leverages existing Radix UI features + +### Implementation Steps: + +#### 1. Create a Custom Hook for Mobile Detection +**File**: `/apps/ui/src/hooks/use-mobile.ts` (new file) + +```typescript +import { useEffect, useState } from 'react'; + +export function useMobile(breakpoint: number = 768) { + const [isMobile, setIsMobile] = useState(false); + + useEffect(() => { + const mediaQuery = window.matchMedia(`(max-width: ${breakpoint}px)`); + + const handleChange = () => { + setIsMobile(mediaQuery.matches); + }; + + // Set initial value + handleChange(); + + // Listen for changes + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, [breakpoint]); + + return isMobile; +} +``` + +**Why**: Follows existing pattern from `use-sidebar-auto-collapse.ts`, reusable across components + +#### 2. Update Phase Model Selector with Responsive Behavior +**File**: `/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx` + +**Changes**: +- Import and use `useMobile()` hook +- Apply responsive widths: + - Mobile: `w-[calc(100vw-32px)] max-w-[340px]` (full width with padding) + - Desktop: `w-80` (320px - current) +- Add collision handling to Radix Popover: + - `collisionPadding={16}` - Prevent edge overflow + - `avoidCollisions={true}` - Enable collision detection + - `sideOffset={4}` - Add spacing from trigger +- Secondary popovers: + - Mobile: Position `side="bottom"` instead of `side="right"` + - Desktop: Keep `side="right"` (current behavior) + - Mobile width: `w-[calc(100vw-32px)] max-w-[340px]` + - Desktop width: `w-[220px]` (current) + +**Specific Code Changes**: +```typescript +// Add at top of component +const isMobile = useMobile(768); + +// Main popover content + + +// Secondary popovers (thinking level, reasoning effort, etc.) + +``` + +#### 3. Test Responsive Behavior + +**Test Cases**: +- [ ] Mobile (< 768px): Popovers fit within screen, secondary popovers open below +- [ ] Tablet (768-1024px): Popovers use optimal width +- [ ] Desktop (> 1024px): Current behavior preserved +- [ ] Edge cases: Very narrow screens (320px), screen rotation +- [ ] Functionality: All model selections work correctly on all screen sizes + +## Alternative Approaches Considered + +### Alternative 1: Use Sheet Component for Mobile +**Pros**: Better mobile UX, full-screen takeover common pattern +**Cons**: Requires duplicating component logic, more complex state management, different UX between mobile/desktop + +**Verdict**: Rejected - Too much complexity for the benefit + +### Alternative 2: Simplify Mobile UI (Remove Nested Popovers) +**Pros**: Simpler mobile interface +**Cons**: Removes functionality (thinking levels, reasoning effort) on mobile, poor UX + +**Verdict**: Rejected - Removes essential features + +### Alternative 3: Horizontal Scrolling Container +**Pros**: Preserves exact desktop layout +**Cons**: Poor mobile UX, non-standard pattern, accessibility issues + +**Verdict**: Rejected - Bad mobile UX + +## Technical Considerations + +### Breakpoint Selection +- **768px chosen**: Standard tablet breakpoint +- Matches pattern in existing codebase (`use-sidebar-auto-collapse.ts` uses 1024px) +- Covers iPhone SE (375px) through iPhone 14 Pro Max (428px) + +### Collision Handling +- `collisionPadding={16}`: 16px buffer from edges (standard spacing) +- `avoidCollisions={true}`: Radix will automatically reposition if needed +- `sideOffset={4}`: Small gap between trigger and popover + +### Performance +- `useMobile` hook uses `window.matchMedia` (performant, native API) +- Re-renders only on breakpoint changes (not every resize) +- No additional dependencies + +### Compatibility +- Works with existing compact/full modes +- Preserves all functionality +- No breaking changes to props/API +- Compatible with existing styles + +## Implementation Checklist + +- [ ] Create `/apps/ui/src/hooks/use-mobile.ts` +- [ ] Update `phase-model-selector.tsx` with responsive behavior +- [ ] Test on mobile devices/emulators (Chrome DevTools) +- [ ] Test on tablet breakpoint +- [ ] Test on desktop (ensure no regression) +- [ ] Verify all model variants are selectable +- [ ] Check nested popovers (thinking level, reasoning effort, cursor) +- [ ] Verify compact mode still works in agent view +- [ ] Test keyboard navigation +- [ ] Test with touch interactions + +## Rollback Plan +If issues arise: +1. Revert `phase-model-selector.tsx` changes +2. Remove `use-mobile.ts` hook +3. Original functionality immediately restored + +## Success Criteria +✅ Users can select any model on mobile devices (< 768px width) +✅ All nested popover options are accessible on mobile +✅ Desktop behavior unchanged (no regressions) +✅ UI fits within viewport on all screen sizes (320px+) +✅ No horizontal scrolling required +✅ Touch interactions work correctly + +## Estimated Effort +- Implementation: 30-45 minutes +- Testing: 15-20 minutes +- **Total**: ~1 hour + +## Dependencies +None - uses existing Radix UI Popover features + +## Risks & Mitigation +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Breaks desktop layout | Low | Medium | Thorough testing, conditional logic | +| Poor mobile UX | Low | Medium | Follow mobile-first best practices | +| Touch interaction issues | Low | Low | Use Radix UI touch handlers | +| Breakpoint conflicts | Low | Low | Use standard 768px breakpoint | + +## Notes for Developer +- The `compact` prop in `agent-model-selector.tsx` is preserved and still works +- All existing functionality (thinking levels, reasoning effort, cursor variants) remains +- Only visual layout changes on mobile - no logic changes +- Consider adding this pattern to other popovers if successful diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index db5a4d2f..0fd11065 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -1,6 +1,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; +import { useIsMobile } from '@/hooks/use-media-query'; import type { ModelAlias, CursorModelId, @@ -167,6 +168,9 @@ export function PhaseModelSelector({ dynamicOpencodeModels, } = useAppStore(); + // Detect mobile devices to use inline expansion instead of nested popovers + const isMobile = useIsMobile(); + // Extract model and thinking/reasoning levels from value const selectedModel = value.model; const selectedThinkingLevel = value.thinkingLevel || 'none'; @@ -585,6 +589,107 @@ export function PhaseModelSelector({ } // Model supports reasoning - show popover with reasoning effort options + // On mobile, render inline expansion instead of nested popover + if (isMobile) { + return ( +
+ setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + + {isSelected && currentReasoning !== 'none' + ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` + : model.description} + +
+
+ +
+ + {isSelected && !isExpanded && } + +
+
+ + {/* Inline reasoning effort options on mobile */} + {isExpanded && ( +
+
+ Reasoning Effort +
+ {REASONING_EFFORT_LEVELS.map((effort) => ( + + ))} +
+ )} +
+ ); + } + + // Desktop: Use nested popover return ( + setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + + {isSelected && currentThinking !== 'none' + ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` + : model.description} + +
+
+ +
+ + {isSelected && !isExpanded && } + +
+
+ + {/* Inline thinking level options on mobile */} + {isExpanded && ( +
+
+ Thinking Level +
+ {THINKING_LEVELS.map((level) => ( + + ))} +
+ )} + + ); + } + + // Desktop: Use nested popover return ( + setExpandedGroup(isExpanded ? null : group.baseId)} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {group.label} + + + {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description} + +
+
+ +
+ {groupIsSelected && !isExpanded && } + +
+
+ + {/* Inline variant options on mobile */} + {isExpanded && ( +
+
+ {variantTypeLabel} +
+ {group.variants.map((variant) => ( + + ))} +
+ )} + + ); + } + + // Desktop: Use nested popover return ( { + if (typeof window === 'undefined') return false; + return window.matchMedia(query).matches; + }); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const mediaQuery = window.matchMedia(query); + const handleChange = (e: MediaQueryListEvent) => { + setMatches(e.matches); + }; + + // Set initial value + setMatches(mediaQuery.matches); + + // Listen for changes + mediaQuery.addEventListener('change', handleChange); + + return () => { + mediaQuery.removeEventListener('change', handleChange); + }; + }, [query]); + + return matches; +} + +/** + * Hook to detect if the device is mobile (screen width <= 768px) + * @returns boolean indicating if the device is mobile + */ +export function useIsMobile(): boolean { + return useMediaQuery('(max-width: 768px)'); +} + +/** + * Hook to detect if the device is tablet or smaller (screen width <= 1024px) + * @returns boolean indicating if the device is tablet or smaller + */ +export function useIsTablet(): boolean { + return useMediaQuery('(max-width: 1024px)'); +} From e56db2362c5da7b9cf01701c0eecb1e09df837ff Mon Sep 17 00:00:00 2001 From: anonymous Date: Sat, 10 Jan 2026 21:10:33 -0800 Subject: [PATCH 16/91] feat: Add AI-generated commit messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate Claude Haiku to automatically generate commit messages when committing worktree changes. Shows a sparkle animation while generating and auto-populates the commit message field. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/server/src/routes/worktree/index.ts | 7 + .../routes/generate-commit-message.ts | 178 ++++++++++++++++++ .../dialogs/commit-worktree-dialog.tsx | 67 ++++++- apps/ui/src/lib/electron.ts | 9 + apps/ui/src/lib/http-api-client.ts | 2 + apps/ui/src/types/electron.d.ts | 7 + libs/git-utils/src/diff.ts | 2 +- 7 files changed, 265 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/routes/worktree/routes/generate-commit-message.ts diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index a00e0bfe..537a9acd 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -17,6 +17,7 @@ import { createDeleteHandler } from './routes/delete.js'; import { createCreatePRHandler } from './routes/create-pr.js'; import { createPRInfoHandler } from './routes/pr-info.js'; import { createCommitHandler } from './routes/commit.js'; +import { createGenerateCommitMessageHandler } from './routes/generate-commit-message.js'; import { createPushHandler } from './routes/push.js'; import { createPullHandler } from './routes/pull.js'; import { createCheckoutBranchHandler } from './routes/checkout-branch.js'; @@ -64,6 +65,12 @@ export function createWorktreeRoutes(events: EventEmitter): Router { requireGitRepoOnly, createCommitHandler() ); + router.post( + '/generate-commit-message', + validatePathParams('worktreePath'), + requireGitRepoOnly, + createGenerateCommitMessageHandler() + ); router.post( '/push', validatePathParams('worktreePath'), diff --git a/apps/server/src/routes/worktree/routes/generate-commit-message.ts b/apps/server/src/routes/worktree/routes/generate-commit-message.ts new file mode 100644 index 00000000..69e44058 --- /dev/null +++ b/apps/server/src/routes/worktree/routes/generate-commit-message.ts @@ -0,0 +1,178 @@ +/** + * POST /worktree/generate-commit-message endpoint - Generate an AI commit message from git diff + * + * Uses Claude Haiku to generate a concise, conventional commit message from git changes. + */ + +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { query } from '@anthropic-ai/claude-agent-sdk'; +import { createLogger } from '@automaker/utils'; +import { CLAUDE_MODEL_MAP } from '@automaker/model-resolver'; +import { getErrorMessage, logError } from '../common.js'; + +const logger = createLogger('GenerateCommitMessage'); +const execAsync = promisify(exec); + +interface GenerateCommitMessageRequestBody { + worktreePath: string; +} + +interface GenerateCommitMessageSuccessResponse { + success: true; + message: string; +} + +interface GenerateCommitMessageErrorResponse { + success: false; + error: string; +} + +const SYSTEM_PROMPT = `You are a git commit message generator. Your task is to create a clear, concise commit message based on the git diff provided. + +Rules: +- Output ONLY the commit message, nothing else +- First line should be a short summary (50 chars or less) in imperative mood +- Start with a conventional commit type if appropriate (feat:, fix:, refactor:, docs:, etc.) +- Keep it concise and descriptive +- Focus on WHAT changed and WHY (if clear from the diff), not HOW +- No quotes, backticks, or extra formatting +- If there are multiple changes, provide a brief summary on the first line + +Examples: +- feat: Add dark mode toggle to settings +- fix: Resolve login validation edge case +- refactor: Extract user authentication logic +- docs: Update installation instructions`; + +async function extractTextFromStream( + stream: AsyncIterable<{ + type: string; + subtype?: string; + result?: string; + message?: { + content?: Array<{ type: string; text?: string }>; + }; + }> +): Promise { + let responseText = ''; + + for await (const msg of stream) { + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + responseText += block.text; + } + } + } else if (msg.type === 'result' && msg.subtype === 'success') { + responseText = msg.result || responseText; + } + } + + return responseText; +} + +export function createGenerateCommitMessageHandler(): ( + req: Request, + res: Response +) => Promise { + return async (req: Request, res: Response): Promise => { + try { + const { worktreePath } = req.body as GenerateCommitMessageRequestBody; + + if (!worktreePath || typeof worktreePath !== 'string') { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'worktreePath is required and must be a string', + }; + res.status(400).json(response); + return; + } + + logger.info(`Generating commit message for worktree: ${worktreePath}`); + + // Get git diff of staged and unstaged changes + let diff = ''; + try { + // First try to get staged changes + const { stdout: stagedDiff } = await execAsync('git diff --cached', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + + // If no staged changes, get unstaged changes + if (!stagedDiff.trim()) { + const { stdout: unstagedDiff } = await execAsync('git diff', { + cwd: worktreePath, + maxBuffer: 1024 * 1024 * 5, // 5MB buffer + }); + diff = unstagedDiff; + } else { + diff = stagedDiff; + } + } catch (error) { + logger.error('Failed to get git diff:', error); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to get git changes', + }; + res.status(500).json(response); + return; + } + + if (!diff.trim()) { + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'No changes to commit', + }; + res.status(400).json(response); + return; + } + + // Truncate diff if too long (keep first 10000 characters to avoid token limits) + const truncatedDiff = + diff.length > 10000 ? diff.substring(0, 10000) + '\n\n[... diff truncated ...]' : diff; + + const userPrompt = `Generate a commit message for these changes:\n\n\`\`\`diff\n${truncatedDiff}\n\`\`\``; + + const stream = query({ + prompt: userPrompt, + options: { + model: CLAUDE_MODEL_MAP.haiku, + systemPrompt: SYSTEM_PROMPT, + maxTurns: 1, + allowedTools: [], + permissionMode: 'default', + }, + }); + + const message = await extractTextFromStream(stream); + + if (!message || message.trim().length === 0) { + logger.warn('Received empty response from Claude'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: 'Failed to generate commit message - empty response', + }; + res.status(500).json(response); + return; + } + + logger.info(`Generated commit message: ${message.trim().substring(0, 100)}...`); + + const response: GenerateCommitMessageSuccessResponse = { + success: true, + message: message.trim(), + }; + res.json(response); + } catch (error) { + logError(error, 'Generate commit message failed'); + const response: GenerateCommitMessageErrorResponse = { + success: false, + error: getErrorMessage(error), + }; + res.status(500).json(response); + } + }; +} diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx index 8e905c5e..492f671f 100644 --- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, @@ -10,7 +10,7 @@ import { import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { Label } from '@/components/ui/label'; -import { GitCommit, Loader2 } from 'lucide-react'; +import { GitCommit, Loader2, Sparkles } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; import { toast } from 'sonner'; @@ -37,6 +37,7 @@ export function CommitWorktreeDialog({ }: CommitWorktreeDialogProps) { const [message, setMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); const [error, setError] = useState(null); const handleCommit = async () => { @@ -82,6 +83,45 @@ export function CommitWorktreeDialog({ } }; + // Generate AI commit message when dialog opens + useEffect(() => { + if (open && worktree) { + // Reset state + setMessage(''); + setError(null); + setIsGenerating(true); + + const generateMessage = async () => { + try { + const api = getElectronAPI(); + if (!api?.worktree?.generateCommitMessage) { + setError('AI commit message generation not available'); + setIsGenerating(false); + return; + } + + const result = await api.worktree.generateCommitMessage(worktree.path); + + if (result.success && result.message) { + setMessage(result.message); + } else { + // Don't show error toast, just log it and leave message empty + console.warn('Failed to generate commit message:', result.error); + setMessage(''); + } + } catch (err) { + // Don't show error toast for generation failures + console.warn('Error generating commit message:', err); + setMessage(''); + } finally { + setIsGenerating(false); + } + }; + + generateMessage(); + } + }, [open, worktree]); + if (!worktree) return null; return ( @@ -106,10 +146,20 @@ export function CommitWorktreeDialog({
- +