Merge branch 'main' of github.com:webdevcody/automaker

This commit is contained in:
Cody Seibert
2025-12-10 11:47:43 -05:00
47 changed files with 9701 additions and 4060 deletions

View File

@@ -0,0 +1,8 @@
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
❌ Error: Reconnecting... 1/5
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task

View File

@@ -0,0 +1,4 @@
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
📋 Planning implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task
⚡ Executing implementation for: For example i got haiku model running or codex one but we can still see opus 4.5 check if it not hardcoded and fix it to use proper model name that was used in this task

Submodule .automaker/worktrees/176536627888-implement-profile-view-and-in-the-sideba added at a78b6763de

Submodule .automaker/worktrees/176536775869-so-we-added-ai-profiles-add-a-default-op added at a78b6763de

View File

@@ -0,0 +1,453 @@
---
name: Codex CLI OpenAI Model Support
overview: Extend the model support system to integrate OpenAI Codex CLI, enabling users to use OpenAI models (GPT-4o, o3, etc.) alongside existing Claude models. This includes CLI detection, model provider abstraction, execution wrapper, and UI updates.
todos:
- id: model-provider-abstraction
content: Create model provider abstraction layer with base interface and Claude/Codex implementations
status: pending
- id: codex-cli-detector
content: Implement Codex CLI detector service to check installation status and version
status: pending
- id: codex-executor
content: Create Codex CLI execution wrapper that spawns subprocess and parses JSON output
status: pending
- id: codex-config-manager
content: Implement Codex TOML configuration manager for model provider setup
status: pending
- id: model-registry
content: Create centralized model registry with provider mappings and metadata
status: pending
- id: update-feature-executor
content: Refactor feature-executor.js to use model provider abstraction instead of direct SDK calls
status: pending
- id: update-agent-service
content: Update agent-service.js to support configurable model selection via provider abstraction
status: pending
- id: message-converter
content: Create message format converter to translate Codex JSONL output to Claude SDK format
status: pending
- id: update-ui-types
content: Extend TypeScript types in app-store.ts to include OpenAI models and provider metadata
status: pending
- id: update-board-view
content: Expand model selection dropdown in board-view.tsx to include OpenAI models with provider grouping
status: pending
- id: update-settings-view
content: Add OpenAI API key input, Codex CLI status check, and test connection button to settings-view.tsx
status: pending
- id: openai-test-api
content: Create OpenAI API test endpoint at app/src/app/api/openai/test/route.ts
status: pending
- id: ipc-handlers
content: Add IPC handlers in main.js for model management (checkCodexCli, getAvailableModels, testOpenAI)
status: pending
- id: preload-api
content: Update preload.js and electron.d.ts to expose new IPC methods to renderer process
status: pending
- id: env-manager
content: Create environment variable manager for centralized API key and config handling
status: pending
- id: error-handling
content: Implement provider fallback logic and user-friendly error messages for missing CLI/API keys
status: pending
---
# Codex CLI OpenAI Model Support Implementation Plan
## Overview
Extend Automaker's model support to integrate OpenAI Codex CLI, allowing users to use the latest GPT-5.1 Codex models (`gpt-5.1-codex-max`, `gpt-5.1-codex`, `gpt-5.1-codex-mini`, `gpt-5.1`) alongside existing Claude models. Codex CLI defaults to `gpt-5.1-codex-max` and uses ChatGPT Enterprise authentication (no API key required). The implementation will follow the existing Claude CLI pattern but add abstraction for multiple model providers.
## Current Architecture Analysis
### Model Usage Points
1. **Feature Executor** (`app/electron/services/feature-executor.js`):
- Uses `MODEL_MAP` with hardcoded Claude models (haiku, sonnet, opus)
- Calls `@anthropic-ai/claude-agent-sdk` `query()` function
- Model selection via `getModelString(feature)` method
2. **Agent Service** (`app/electron/agent-service.js`):
- Hardcoded model: `"claude-opus-4-5-20251101"`
- Uses Claude Agent SDK directly
3. **API Route** (`app/src/app/api/chat/route.ts`):
- Hardcoded model: `"claude-opus-4-5-20251101"`
- Uses Claude Agent SDK
4. **Project Analyzer** (`app/electron/services/project-analyzer.js`):
- Hardcoded model: `"claude-sonnet-4-20250514"`
5. **UI Components**:
- `board-view.tsx`: Model dropdown (haiku/sonnet/opus)
- `app-store.ts`: `AgentModel` type limited to Claude models
### Authentication
- Claude: Uses `CLAUDE_CODE_OAUTH_TOKEN` environment variable
- Codex: Uses `OPENAI_API_KEY` environment variable (per Codex docs)
## Implementation Strategy
### Phase 1: Model Provider Abstraction Layer
#### 1.1 Create Model Provider Interface
**File**: `app/electron/services/model-provider.js`
- Abstract base class/interface for model providers
- Methods: `executeQuery()`, `detectInstallation()`, `getAvailableModels()`, `validateConfig()`
- Implementations:
- `ClaudeProvider` (wraps existing SDK usage)
- `CodexProvider` (new, wraps Codex CLI execution)
#### 1.2 Create Codex CLI Detector
**File**: `app/electron/services/codex-cli-detector.js`
- Similar to `claude-cli-detector.js`
- Check for `codex` command in PATH
- Check for npm global installation: `npm list -g @openai/codex`
- Check for Homebrew installation on macOS
- Return: `{ installed: boolean, path: string, version: string, method: 'cli'|'npm'|'brew'|'none' }`
#### 1.3 Create Codex Provider Implementation
**File**: `app/electron/services/codex-provider.js`
- Extends model provider interface
- Executes Codex CLI via `child_process.spawn()` or `execSync()`
- Handles JSON output parsing (`codex exec --json`)
- Manages TOML configuration file creation/updates
- Supports latest GPT-5.1 Codex models:
- `gpt-5.1-codex-max` (default, latest flagship for deep and fast reasoning)
- `gpt-5.1-codex` (optimized for codex)
- `gpt-5.1-codex-mini` (cheaper, faster, less capable)
- `gpt-5.1` (broad world knowledge with strong general reasoning)
- Uses ChatGPT Enterprise authentication (no API key required for these models)
- Note: Legacy models (GPT-4o, o3, o1, etc.) are not supported - Codex CLI focuses on GPT-5.1 Codex family only
### Phase 2: Model Configuration System
#### 2.1 Extended Model Registry
**File**: `app/electron/services/model-registry.js`
- Centralized model configuration
- Model definitions with provider mapping:
```javascript
{
id: "claude-opus",
name: "Claude Opus 4.5",
provider: "claude",
modelString: "claude-opus-4-5-20251101",
...
},
{
id: "gpt-4o",
name: "GPT-4o",
provider: "codex",
modelString: "gpt-4o",
requiresApiKey: "OPENAI_API_KEY",
...
}
```
- Model categories: `claude`, `openai`, `azure`, `custom`
#### 2.2 Codex Configuration Manager
**File**: `app/electron/services/codex-config-manager.js`
- Manages Codex TOML config file (typically `~/.config/codex/config.toml` or project-specific)
- Creates/updates model provider configurations:
```toml
[model_providers.openai-chat-completions]
name = "OpenAI using Chat Completions"
base_url = "https://api.openai.com/v1"
env_key = "OPENAI_API_KEY"
wire_api = "chat"
[profiles.gpt4o]
model = "gpt-4o"
model_provider = "openai-chat-completions"
```
- Profile management for different use cases
- Validates configuration before execution
### Phase 3: Execution Integration
#### 3.1 Update Feature Executor
**File**: `app/electron/services/feature-executor.js`
- Replace direct SDK calls with model provider abstraction
- Update `getModelString()` to return model ID instead of string
- Add `getModelProvider(modelId)` method
- Modify `implementFeature()` to:
- Get provider for selected model
- Use provider's `executeQuery()` method
- Handle different response formats (SDK vs CLI JSON)
#### 3.2 Update Agent Service
**File**: `app/electron/agent-service.js`
- Replace hardcoded model with configurable model selection
- Use model provider abstraction
- Support model selection per session
#### 3.3 Update Project Analyzer
**File**: `app/electron/services/project-analyzer.js`
- Use model provider abstraction
- Make model configurable (currently hardcoded to sonnet)
#### 3.4 Update API Route
**File**: `app/src/app/api/chat/route.ts`
- Support model selection from request
- Use model provider abstraction (if running in Electron context)
- Fallback to Claude SDK for web-only usage
### Phase 4: Codex CLI Execution Wrapper
#### 4.1 Codex Executor
**File**: `app/electron/services/codex-executor.js`
- Wraps `codex exec` command execution
- Handles subprocess spawning with proper environment variables
- Parses JSON output (JSONL format from `--json` flag)
- Converts Codex output format to match Claude SDK message format
- Handles streaming responses
- Error handling and timeout management
#### 4.2 Message Format Conversion
**File**: `app/electron/services/message-converter.js`
- Converts Codex JSONL output to Claude SDK message format
- Maps Codex events:
- `thread.started` → session initialization
- `item.completed` (reasoning) → thinking output
- `item.completed` (command_execution) → tool use
- `item.completed` (agent_message) → assistant message
- Maintains compatibility with existing UI components
### Phase 5: UI Updates
#### 5.1 Update Type Definitions
**File**: `app/src/store/app-store.ts`
- Extend `AgentModel` type to include OpenAI models:
```typescript
export type AgentModel =
| "opus" | "sonnet" | "haiku" // Claude
| "gpt-4o" | "gpt-4o-mini" | "gpt-3.5-turbo" | "o3" | "o1"; // OpenAI
```
- Add `modelProvider` field to `Feature` interface
- Add provider metadata to model selection
#### 5.2 Update Board View
**File**: `app/src/components/views/board-view.tsx`
- Expand model dropdown to include OpenAI models
- Group models by provider (Claude / OpenAI)
- Show provider badges/icons
- Display model availability based on CLI detection
- Add tooltips showing model capabilities
#### 5.3 Update Settings View
**File**: `app/src/components/views/settings-view.tsx`
- Add OpenAI API key input field (similar to Anthropic key)
- Add Codex CLI status check (similar to Claude CLI check)
- Show installation instructions if Codex CLI not detected
- Add test connection button for OpenAI API
- Display detected Codex CLI version/path
#### 5.4 Create API Test Route
**File**: `app/src/app/api/openai/test/route.ts`
- Similar to `app/src/app/api/claude/test/route.ts`
- Test OpenAI API connection
- Validate API key format
- Return connection status
### Phase 6: Configuration & Environment
#### 6.1 Environment Variable Management
**File**: `app/electron/services/env-manager.js`
- Centralized environment variable handling
- Loads from `.env` file and system environment
- Validates required variables per provider
- Provides fallback mechanisms
#### 6.2 IPC Handlers for Model Management
**File**: `app/electron/main.js`
- Add IPC handlers:
- `model:checkCodexCli` - Check Codex CLI installation
- `model:getAvailableModels` - List available models per provider
- `model:testOpenAI` - Test OpenAI API connection
- `model:updateCodexConfig` - Update Codex TOML config
#### 6.3 Preload API Updates
**File**: `app/electron/preload.js`
- Expose new IPC methods to renderer
- Add TypeScript definitions in `app/src/types/electron.d.ts`
### Phase 7: Error Handling & Fallbacks
#### 7.1 Provider Fallback Logic
- If Codex CLI not available, fallback to Claude
- If OpenAI API key missing, show clear error messages
- Graceful degradation when provider unavailable
#### 7.2 Error Messages
- User-friendly error messages for missing CLI
- Installation instructions per platform
- API key validation errors
- Model availability warnings
## File Structure Summary
### New Files
```
app/electron/services/
├── model-provider.js # Abstract provider interface
├── claude-provider.js # Claude SDK wrapper
├── codex-provider.js # Codex CLI wrapper
├── codex-cli-detector.js # Codex CLI detection
├── codex-executor.js # Codex CLI execution wrapper
├── codex-config-manager.js # TOML config management
├── model-registry.js # Centralized model definitions
├── message-converter.js # Format conversion utilities
└── env-manager.js # Environment variable management
app/src/app/api/openai/
└── test/route.ts # OpenAI API test endpoint
```
### Modified Files
```
app/electron/services/
├── feature-executor.js # Use model provider abstraction
├── agent-service.js # Support multiple providers
└── project-analyzer.js # Configurable model selection
app/electron/
├── main.js # Add IPC handlers
└── preload.js # Expose new APIs
app/src/
├── store/app-store.ts # Extended model types
├── components/views/
│ ├── board-view.tsx # Expanded model selection UI
│ └── settings-view.tsx # OpenAI API key & Codex CLI status
└── types/electron.d.ts # Updated IPC type definitions
```
## Implementation Details
### Codex CLI Execution Pattern
```javascript
// Example execution flow
const codexExecutor = require('./codex-executor');
const result = await codexExecutor.execute({
prompt: "Implement feature X",
model: "gpt-4o",
cwd: projectPath,
systemPrompt: "...",
maxTurns: 20,
allowedTools: ["Read", "Write", "Edit", "Bash"],
env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY }
});
```
### Model Provider Interface
```javascript
class ModelProvider {
async executeQuery(options) {
// Returns async generator of messages
}
async detectInstallation() {
// Returns installation status
}
getAvailableModels() {
// Returns list of supported models
}
validateConfig() {
// Validates provider configuration
}
}
```
### Configuration File Location
- User config: `~/.config/codex/config.toml` (or platform equivalent)
- Project config: `.codex/config.toml` (optional, project-specific)
- Fallback: In-memory config passed via CLI args
## Testing Considerations
1. **CLI Detection**: Test on macOS, Linux, Windows
2. **Model Execution**: Test with different OpenAI models
3. **Error Handling**: Test missing CLI, invalid API keys, network errors
4. **Format Conversion**: Verify message format compatibility
5. **Concurrent Execution**: Test multiple features with different providers
6. **Fallback Logic**: Test provider fallback scenarios
## Documentation Updates
1. Update README with Codex CLI installation instructions:
- `npm install -g @openai/codex@latest` or `brew install codex`
- ChatGPT Enterprise authentication (no API key needed)
- API-based authentication for older models
2. Add model selection guide:
- GPT-5.1 Codex Max (default, best for coding)
- o3/o4-mini with reasoning efforts
- GPT-5.1/GPT-5 with verbosity control
3. Document reasoning effort and verbosity settings
4. Add troubleshooting section for common issues
5. Document model list discovery via MCP interface
## Migration Path
1. Implement provider abstraction alongside existing code
2. Add Codex support without breaking existing Claude functionality
3. Gradually migrate services to use abstraction layer
4. Maintain backward compatibility during transition
5. Remove hardcoded models after full migration

View File

@@ -3,6 +3,7 @@ const featureExecutor = require("./services/feature-executor");
const featureVerifier = require("./services/feature-verifier");
const contextManager = require("./services/context-manager");
const projectAnalyzer = require("./services/project-analyzer");
const worktreeManager = require("./services/worktree-manager");
/**
* Auto Mode Service - Autonomous feature implementation
@@ -33,13 +34,78 @@ class AutoModeService {
const context = {
abortController: null,
query: null,
projectPath: null,
projectPath: null, // Original project path
worktreePath: null, // Path to worktree (where agent works)
branchName: null, // Feature branch name
sendToRenderer: null,
isActive: () => this.runningFeatures.has(featureId),
};
return context;
}
/**
* Setup worktree for a feature
* Creates an isolated git worktree where the agent can work
* @param {Object} feature - The feature object
* @param {string} projectPath - Path to the project
* @param {Function} sendToRenderer - Function to send events to the renderer
* @param {boolean} useWorktreesEnabled - Whether worktrees are enabled in settings (default: false)
*/
async setupWorktreeForFeature(feature, projectPath, sendToRenderer, useWorktreesEnabled = false) {
// If worktrees are disabled in settings, skip entirely
if (!useWorktreesEnabled) {
console.log(`[AutoMode] Worktrees disabled in settings, working directly on main project`);
return { useWorktree: false, workPath: projectPath };
}
// Check if worktrees are enabled (project must be a git repo)
const isGit = await worktreeManager.isGitRepo(projectPath);
if (!isGit) {
console.log(`[AutoMode] Project is not a git repo, skipping worktree creation`);
return { useWorktree: false, workPath: projectPath };
}
sendToRenderer({
type: "auto_mode_progress",
featureId: feature.id,
content: "Creating isolated worktree for feature...\n",
});
const result = await worktreeManager.createWorktree(projectPath, feature);
if (!result.success) {
console.warn(`[AutoMode] Failed to create worktree: ${result.error}. Falling back to main project.`);
sendToRenderer({
type: "auto_mode_progress",
featureId: feature.id,
content: `Warning: Could not create worktree (${result.error}). Working directly on main project.\n`,
});
return { useWorktree: false, workPath: projectPath };
}
console.log(`[AutoMode] Created worktree at: ${result.worktreePath}, branch: ${result.branchName}`);
sendToRenderer({
type: "auto_mode_progress",
featureId: feature.id,
content: `Working in isolated branch: ${result.branchName}\n`,
});
// Update feature with worktree info in feature_list.json
await featureLoader.updateFeatureWorktree(
feature.id,
projectPath,
result.worktreePath,
result.branchName
);
return {
useWorktree: true,
workPath: result.worktreePath,
branchName: result.branchName,
baseBranch: result.baseBranch,
};
}
/**
* Start auto mode - continuously implement features
*/
@@ -108,14 +174,18 @@ class AutoModeService {
/**
* Run a specific feature by ID
* @param {string} projectPath - Path to the project
* @param {string} featureId - ID of the feature to run
* @param {Function} sendToRenderer - Function to send events to renderer
* @param {boolean} useWorktrees - Whether to use git worktree isolation (default: false)
*/
async runFeature({ projectPath, featureId, sendToRenderer }) {
async runFeature({ projectPath, featureId, sendToRenderer, useWorktrees = false }) {
// Check if this specific feature is already running
if (this.runningFeatures.has(featureId)) {
throw new Error(`Feature ${featureId} is already running`);
}
console.log(`[AutoMode] Running specific feature: ${featureId}`);
console.log(`[AutoMode] Running specific feature: ${featureId} (worktrees: ${useWorktrees})`);
// Register this feature as running
const execution = this.createExecutionContext(featureId);
@@ -134,6 +204,14 @@ class AutoModeService {
console.log(`[AutoMode] Running feature: ${feature.description}`);
// Setup worktree for isolated work (if enabled)
const worktreeSetup = await this.setupWorktreeForFeature(feature, projectPath, sendToRenderer, useWorktrees);
execution.worktreePath = worktreeSetup.workPath;
execution.branchName = worktreeSetup.branchName;
// Determine working path (worktree or main project)
const workPath = worktreeSetup.workPath;
// Update feature status to in_progress
await featureLoader.updateFeatureStatus(
featureId,
@@ -144,24 +222,27 @@ class AutoModeService {
sendToRenderer({
type: "auto_mode_feature_start",
featureId: feature.id,
feature: feature,
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
});
// Implement the feature
// Implement the feature (agent works in worktree)
const result = await featureExecutor.implementFeature(
feature,
projectPath,
workPath, // Use worktree path instead of main project
sendToRenderer,
execution
);
// Update feature status based on result
// For skipTests features, go to waiting_approval on success instead of verified
// On failure, skipTests features should also go to waiting_approval for user review
let newStatus;
if (result.passes) {
newStatus = feature.skipTests ? "waiting_approval" : "verified";
} else {
newStatus = "backlog";
// For skipTests features, keep in waiting_approval so user can review
// For normal TDD features, move to backlog for retry
newStatus = feature.skipTests ? "waiting_approval" : "backlog";
}
await featureLoader.updateFeatureStatus(
feature.id,
@@ -554,8 +635,12 @@ class AutoModeService {
/**
* Start a feature asynchronously (similar to drag operation)
* @param {Object} feature - The feature to start
* @param {string} projectPath - Path to the project
* @param {Function} sendToRenderer - Function to send events to renderer
* @param {boolean} useWorktrees - Whether to use git worktree isolation (default: false)
*/
async startFeatureAsync(feature, projectPath, sendToRenderer) {
async startFeatureAsync(feature, projectPath, sendToRenderer, useWorktrees = false) {
const featureId = feature.id;
// Skip if already running
@@ -566,7 +651,7 @@ class AutoModeService {
try {
console.log(
`[AutoMode] Starting feature: ${feature.description.slice(0, 50)}...`
`[AutoMode] Starting feature: ${feature.description.slice(0, 50)}... (worktrees: ${useWorktrees})`
);
// Register this feature as running
@@ -575,6 +660,14 @@ class AutoModeService {
execution.sendToRenderer = sendToRenderer;
this.runningFeatures.set(featureId, execution);
// Setup worktree for isolated work (if enabled)
const worktreeSetup = await this.setupWorktreeForFeature(feature, projectPath, sendToRenderer, useWorktrees);
execution.worktreePath = worktreeSetup.workPath;
execution.branchName = worktreeSetup.branchName;
// Determine working path (worktree or main project)
const workPath = worktreeSetup.workPath;
// Update status to in_progress with timestamp
await featureLoader.updateFeatureStatus(
featureId,
@@ -585,23 +678,27 @@ class AutoModeService {
sendToRenderer({
type: "auto_mode_feature_start",
featureId: feature.id,
feature: feature,
feature: { ...feature, worktreePath: worktreeSetup.workPath, branchName: worktreeSetup.branchName },
});
// Implement the feature (this runs async in background)
// Implement the feature (agent works in worktree)
const result = await featureExecutor.implementFeature(
feature,
projectPath,
workPath, // Use worktree path instead of main project
sendToRenderer,
execution
);
// Update feature status based on result
// For skipTests features, go to waiting_approval on success instead of verified
// On failure, skipTests features should also go to waiting_approval for user review
let newStatus;
if (result.passes) {
newStatus = feature.skipTests ? "waiting_approval" : "verified";
} else {
newStatus = "backlog";
// For skipTests features, keep in waiting_approval so user can review
// For normal TDD features, move to backlog for retry
newStatus = feature.skipTests ? "waiting_approval" : "backlog";
}
await featureLoader.updateFeatureStatus(
feature.id,
@@ -975,6 +1072,170 @@ class AutoModeService {
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Revert feature changes by removing the worktree
* This effectively discards all changes made by the agent
*/
async revertFeature({ projectPath, featureId, sendToRenderer }) {
console.log(`[AutoMode] Reverting feature: ${featureId}`);
try {
// Stop the feature if it's running
if (this.runningFeatures.has(featureId)) {
await this.stopFeature({ featureId });
}
// Remove the worktree and delete the branch
const result = await worktreeManager.removeWorktree(projectPath, featureId, true);
if (!result.success) {
throw new Error(result.error || "Failed to remove worktree");
}
// Clear worktree info from feature
await featureLoader.updateFeatureWorktree(featureId, projectPath, null, null);
// Update feature status back to backlog
await featureLoader.updateFeatureStatus(featureId, "backlog", projectPath);
// Delete context file
await contextManager.deleteContextFile(projectPath, featureId);
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_feature_complete",
featureId: featureId,
passes: false,
message: "Feature reverted - all changes discarded",
});
}
console.log(`[AutoMode] Feature ${featureId} reverted successfully`);
return { success: true, removedPath: result.removedPath };
} catch (error) {
console.error("[AutoMode] Error reverting feature:", error);
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_error",
error: error.message,
featureId: featureId,
});
}
return { success: false, error: error.message };
}
}
/**
* Merge feature worktree changes back to main branch
*/
async mergeFeature({ projectPath, featureId, options = {}, sendToRenderer }) {
console.log(`[AutoMode] Merging feature: ${featureId}`);
try {
// Load feature to get worktree info
const features = await featureLoader.loadFeatures(projectPath);
const feature = features.find((f) => f.id === featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_progress",
featureId: featureId,
content: "Merging feature branch into main...\n",
});
}
// Merge the worktree
const result = await worktreeManager.mergeWorktree(projectPath, featureId, {
...options,
cleanup: true, // Remove worktree after successful merge
});
if (!result.success) {
throw new Error(result.error || "Failed to merge worktree");
}
// Clear worktree info from feature
await featureLoader.updateFeatureWorktree(featureId, projectPath, null, null);
// Update feature status to verified
await featureLoader.updateFeatureStatus(featureId, "verified", projectPath);
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_feature_complete",
featureId: featureId,
passes: true,
message: `Feature merged into ${result.intoBranch}`,
});
}
console.log(`[AutoMode] Feature ${featureId} merged successfully`);
return { success: true, mergedBranch: result.mergedBranch };
} catch (error) {
console.error("[AutoMode] Error merging feature:", error);
if (sendToRenderer) {
sendToRenderer({
type: "auto_mode_error",
error: error.message,
featureId: featureId,
});
}
return { success: false, error: error.message };
}
}
/**
* Get worktree info for a feature
*/
async getWorktreeInfo({ projectPath, featureId }) {
return await worktreeManager.getWorktreeInfo(projectPath, featureId);
}
/**
* Get worktree status (changed files, commits, etc.)
*/
async getWorktreeStatus({ projectPath, featureId }) {
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
if (!worktreeInfo.success) {
return { success: false, error: "Worktree not found" };
}
return await worktreeManager.getWorktreeStatus(worktreeInfo.worktreePath);
}
/**
* List all feature worktrees
*/
async listWorktrees({ projectPath }) {
const worktrees = await worktreeManager.getAllFeatureWorktrees(projectPath);
return { success: true, worktrees };
}
/**
* Get file diffs for a feature worktree
*/
async getFileDiffs({ projectPath, featureId }) {
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
if (!worktreeInfo.success) {
return { success: false, error: "Worktree not found" };
}
return await worktreeManager.getFileDiffs(worktreeInfo.worktreePath);
}
/**
* Get diff for a specific file in a feature worktree
*/
async getFileDiff({ projectPath, featureId, filePath }) {
const worktreeInfo = await worktreeManager.getWorktreeInfo(projectPath, featureId);
if (!worktreeInfo.success) {
return { success: false, error: "Worktree not found" };
}
return await worktreeManager.getFileDiff(worktreeInfo.worktreePath, filePath);
}
}
// Export singleton instance

View File

@@ -7,6 +7,7 @@ const { app, BrowserWindow, ipcMain, dialog, shell } = require("electron");
const fs = require("fs/promises");
const agentService = require("./agent-service");
const autoModeService = require("./auto-mode-service");
const worktreeManager = require("./services/worktree-manager");
const featureSuggestionsService = require("./services/feature-suggestions-service");
const specRegenerationService = require("./services/spec-regeneration-service");
@@ -61,6 +62,21 @@ app.whenReady().then(async () => {
const appDataPath = app.getPath("userData");
await agentService.initialize(appDataPath);
// Pre-load allowed paths from agent history to prevent breaking "Recent Projects"
try {
const sessions = await agentService.listSessions({ includeArchived: true });
sessions.forEach((session) => {
if (session.projectPath) {
addAllowedPath(session.projectPath);
}
});
console.log(
`[Security] Pre-loaded ${allowedPaths.size} allowed paths from history`
);
} catch (error) {
console.error("Failed to load sessions for security whitelist:", error);
}
createWindow();
app.on("activate", () => {
@@ -76,6 +92,43 @@ app.on("window-all-closed", () => {
}
});
// Track allowed paths for file operations (security)
const allowedPaths = new Set();
/**
* Add a path to the allowed list
*/
function addAllowedPath(pathToAdd) {
if (!pathToAdd) return;
allowedPaths.add(path.resolve(pathToAdd));
console.log(`[Security] Added allowed path: ${pathToAdd}`);
}
/**
* Check if a file path is allowed (must be within an allowed directory)
*/
function isPathAllowed(filePath) {
const resolvedPath = path.resolve(filePath);
// Allow access to app data directory (for logs, temp images etc)
const appDataPath = app.getPath("userData");
if (resolvedPath.startsWith(appDataPath)) return true;
// Check against all allowed project paths
for (const allowedPath of allowedPaths) {
// Check if path starts with allowed directory
// Ensure we don't match "/foo/bar" against "/foo/b"
if (
resolvedPath === allowedPath ||
resolvedPath.startsWith(allowedPath + path.sep)
) {
return true;
}
}
return false;
}
// IPC Handlers
// Dialog handlers
@@ -83,6 +136,11 @@ ipcMain.handle("dialog:openDirectory", async () => {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ["openDirectory", "createDirectory"],
});
if (!result.canceled && result.filePaths.length > 0) {
result.filePaths.forEach((p) => addAllowedPath(p));
}
return result;
});
@@ -91,12 +149,26 @@ ipcMain.handle("dialog:openFile", async (_, options = {}) => {
properties: ["openFile"],
...options,
});
if (!result.canceled && result.filePaths.length > 0) {
// Allow reading the specific file selected
result.filePaths.forEach((p) => addAllowedPath(p));
}
return result;
});
// File system handlers
ipcMain.handle("fs:readFile", async (_, filePath) => {
try {
// Security check
if (!isPathAllowed(filePath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const content = await fs.readFile(filePath, "utf-8");
return { success: true, content };
} catch (error) {
@@ -106,6 +178,14 @@ ipcMain.handle("fs:readFile", async (_, filePath) => {
ipcMain.handle("fs:writeFile", async (_, filePath, content) => {
try {
// Security check
if (!isPathAllowed(filePath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
await fs.writeFile(filePath, content, "utf-8");
return { success: true };
} catch (error) {
@@ -115,6 +195,14 @@ ipcMain.handle("fs:writeFile", async (_, filePath, content) => {
ipcMain.handle("fs:mkdir", async (_, dirPath) => {
try {
// Security check
if (!isPathAllowed(dirPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
await fs.mkdir(dirPath, { recursive: true });
return { success: true };
} catch (error) {
@@ -124,6 +212,14 @@ ipcMain.handle("fs:mkdir", async (_, dirPath) => {
ipcMain.handle("fs:readdir", async (_, dirPath) => {
try {
// Security check
if (!isPathAllowed(dirPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const entries = await fs.readdir(dirPath, { withFileTypes: true });
const result = entries.map((entry) => ({
name: entry.name,
@@ -138,6 +234,11 @@ ipcMain.handle("fs:readdir", async (_, dirPath) => {
ipcMain.handle("fs:exists", async (_, filePath) => {
try {
// Exists check is generally safe, but we can restrict it too for strict privacy
if (!isPathAllowed(filePath)) {
return false;
}
await fs.access(filePath);
return true;
} catch {
@@ -147,6 +248,14 @@ ipcMain.handle("fs:exists", async (_, filePath) => {
ipcMain.handle("fs:stat", async (_, filePath) => {
try {
// Security check
if (!isPathAllowed(filePath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
const stats = await fs.stat(filePath);
return {
success: true,
@@ -164,6 +273,14 @@ ipcMain.handle("fs:stat", async (_, filePath) => {
ipcMain.handle("fs:deleteFile", async (_, filePath) => {
try {
// Security check
if (!isPathAllowed(filePath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
await fs.unlink(filePath);
return { success: true };
} catch (error) {
@@ -173,6 +290,14 @@ ipcMain.handle("fs:deleteFile", async (_, filePath) => {
ipcMain.handle("fs:trashItem", async (_, targetPath) => {
try {
// Security check
if (!isPathAllowed(targetPath)) {
return {
success: false,
error: "Access denied: Path is outside allowed project directories",
};
}
await shell.trashItem(targetPath);
return { success: true };
} catch (error) {
@@ -352,6 +477,10 @@ ipcMain.handle(
"sessions:create",
async (_, { name, projectPath, workingDirectory }) => {
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
if (workingDirectory) addAllowedPath(workingDirectory);
return await agentService.createSession({
name,
projectPath,
@@ -423,6 +552,9 @@ ipcMain.handle(
"auto-mode:start",
async (_, { projectPath, maxConcurrency }) => {
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("auto-mode:event", data);
@@ -470,7 +602,7 @@ ipcMain.handle("auto-mode:status", () => {
*/
ipcMain.handle(
"auto-mode:run-feature",
async (_, { projectPath, featureId }) => {
async (_, { projectPath, featureId, useWorktrees = false }) => {
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
@@ -482,6 +614,7 @@ ipcMain.handle(
projectPath,
featureId,
sendToRenderer,
useWorktrees,
});
} catch (error) {
console.error("[IPC] auto-mode:run-feature error:", error);
@@ -581,6 +714,9 @@ ipcMain.handle(
ipcMain.handle("auto-mode:analyze-project", async (_, { projectPath }) => {
console.log("[IPC] auto-mode:analyze-project called with:", { projectPath });
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("auto-mode:event", data);
@@ -672,6 +808,111 @@ ipcMain.handle(
}
);
// ============================================================================
// Claude CLI Detection IPC Handlers
// ============================================================================
/**
* Check Claude Code CLI installation status
*/
ipcMain.handle("claude:check-cli", async () => {
try {
const claudeCliDetector = require("./services/claude-cli-detector");
const info = claudeCliDetector.getInstallationInfo();
return { success: true, ...info };
} catch (error) {
console.error("[IPC] claude:check-cli error:", error);
return { success: false, error: error.message };
}
});
// ============================================================================
// Codex CLI Detection IPC Handlers
// ============================================================================
/**
* Check Codex CLI installation status
*/
ipcMain.handle("codex:check-cli", async () => {
try {
const codexCliDetector = require("./services/codex-cli-detector");
const info = codexCliDetector.getInstallationInfo();
return { success: true, ...info };
} catch (error) {
console.error("[IPC] codex:check-cli error:", error);
return { success: false, error: error.message };
}
});
/**
* Get all available models from all providers
*/
ipcMain.handle("model:get-available", async () => {
try {
const { ModelProviderFactory } = require("./services/model-provider");
const models = ModelProviderFactory.getAllModels();
return { success: true, models };
} catch (error) {
console.error("[IPC] model:get-available error:", error);
return { success: false, error: error.message };
}
});
/**
* Check all provider installation status
*/
ipcMain.handle("model:check-providers", async () => {
try {
const { ModelProviderFactory } = require("./services/model-provider");
const status = await ModelProviderFactory.checkAllProviders();
return { success: true, providers: status };
} catch (error) {
console.error("[IPC] model:check-providers error:", error);
return { success: false, error: error.message };
}
});
// ============================================================================
// MCP Server IPC Handlers
// ============================================================================
/**
* Handle MCP server callback for updating feature status
* This can be called by the MCP server script via HTTP or other communication mechanism
* Note: The MCP server script runs as a separate process, so it can't directly use Electron IPC.
* For now, the MCP server calls featureLoader.updateFeatureStatus directly.
* This handler is here for future extensibility (e.g., HTTP endpoint bridge).
*/
ipcMain.handle(
"mcp:update-feature-status",
async (_, { featureId, status, projectPath, summary }) => {
try {
const featureLoader = require("./services/feature-loader");
await featureLoader.updateFeatureStatus(
featureId,
status,
projectPath,
summary
);
// Notify renderer if window is available
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("mcp:feature-status-updated", {
featureId,
status,
projectPath,
summary,
});
}
return { success: true };
} catch (error) {
console.error("[IPC] mcp:update-feature-status error:", error);
return { success: false, error: error.message };
}
}
);
// ============================================================================
// Feature Suggestions IPC Handlers
// ============================================================================
@@ -682,53 +923,53 @@ let suggestionsExecution = null;
/**
* Generate feature suggestions by analyzing the project
*/
ipcMain.handle(
"suggestions:generate",
async (_, { projectPath }) => {
console.log("[IPC] suggestions:generate called with:", { projectPath });
ipcMain.handle("suggestions:generate", async (_, { projectPath }) => {
console.log("[IPC] suggestions:generate called with:", { projectPath });
try {
// Check if already running
if (suggestionsExecution && suggestionsExecution.isActive()) {
return { success: false, error: "Suggestions generation is already running" };
}
// Create execution context
suggestionsExecution = {
abortController: null,
query: null,
isActive: () => suggestionsExecution !== null,
try {
// Check if already running
if (suggestionsExecution && suggestionsExecution.isActive()) {
return {
success: false,
error: "Suggestions generation is already running",
};
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("suggestions:event", data);
}
};
// Start generating suggestions (runs in background)
featureSuggestionsService
.generateSuggestions(projectPath, sendToRenderer, suggestionsExecution)
.catch((error) => {
console.error("[IPC] suggestions:generate background error:", error);
sendToRenderer({
type: "suggestions_error",
error: error.message,
});
})
.finally(() => {
suggestionsExecution = null;
});
// Return immediately
return { success: true };
} catch (error) {
console.error("[IPC] suggestions:generate error:", error);
suggestionsExecution = null;
return { success: false, error: error.message };
}
// Create execution context
suggestionsExecution = {
abortController: null,
query: null,
isActive: () => suggestionsExecution !== null,
};
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("suggestions:event", data);
}
};
// Start generating suggestions (runs in background)
featureSuggestionsService
.generateSuggestions(projectPath, sendToRenderer, suggestionsExecution)
.catch((error) => {
console.error("[IPC] suggestions:generate background error:", error);
sendToRenderer({
type: "suggestions_error",
error: error.message,
});
})
.finally(() => {
suggestionsExecution = null;
});
// Return immediately
return { success: true };
} catch (error) {
console.error("[IPC] suggestions:generate error:", error);
suggestionsExecution = null;
return { success: false, error: error.message };
}
);
});
/**
* Stop the current suggestions generation
@@ -757,6 +998,79 @@ ipcMain.handle("suggestions:status", () => {
};
});
// ============================================================================
// OpenAI API Handlers
// ============================================================================
/**
* Test OpenAI API connection
*/
ipcMain.handle("openai:test-connection", async (_, { apiKey }) => {
try {
// Simple test using fetch to OpenAI API
const response = await fetch("https://api.openai.com/v1/models", {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey || process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
const data = await response.json();
return {
success: true,
message: `Connected successfully. Found ${
data.data?.length || 0
} models.`,
};
} else {
const error = await response.json();
return {
success: false,
error: error.error?.message || "Failed to connect to OpenAI API",
};
}
} catch (error) {
console.error("[IPC] openai:test-connection error:", error);
return { success: false, error: error.message };
}
});
// ============================================================================
// Worktree Management IPC Handlers
// ============================================================================
/**
* Revert feature changes by removing the worktree
* This effectively discards all changes made by the agent
*/
ipcMain.handle(
"worktree:revert-feature",
async (_, { projectPath, featureId }) => {
console.log("[IPC] worktree:revert-feature called with:", {
projectPath,
featureId,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("auto-mode:event", data);
}
};
return await autoModeService.revertFeature({
projectPath,
featureId,
sendToRenderer,
});
} catch (error) {
console.error("[IPC] worktree:revert-feature error:", error);
return { success: false, error: error.message };
}
}
);
// ============================================================================
// Spec Regeneration IPC Handlers
// ============================================================================
@@ -770,12 +1084,20 @@ let specRegenerationExecution = null;
ipcMain.handle(
"spec-regeneration:generate",
async (_, { projectPath, projectDefinition }) => {
console.log("[IPC] spec-regeneration:generate called with:", { projectPath });
console.log("[IPC] spec-regeneration:generate called with:", {
projectPath,
});
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
// Check if already running
if (specRegenerationExecution && specRegenerationExecution.isActive()) {
return { success: false, error: "Spec regeneration is already running" };
return {
success: false,
error: "Spec regeneration is already running",
};
}
// Create execution context
@@ -793,9 +1115,17 @@ ipcMain.handle(
// Start regenerating spec (runs in background)
specRegenerationService
.regenerateSpec(projectPath, projectDefinition, sendToRenderer, specRegenerationExecution)
.regenerateSpec(
projectPath,
projectDefinition,
sendToRenderer,
specRegenerationExecution
)
.catch((error) => {
console.error("[IPC] spec-regeneration:generate background error:", error);
console.error(
"[IPC] spec-regeneration:generate background error:",
error
);
sendToRenderer({
type: "spec_regeneration_error",
error: error.message,
@@ -821,7 +1151,10 @@ ipcMain.handle(
ipcMain.handle("spec-regeneration:stop", async () => {
console.log("[IPC] spec-regeneration:stop called");
try {
if (specRegenerationExecution && specRegenerationExecution.abortController) {
if (
specRegenerationExecution &&
specRegenerationExecution.abortController
) {
specRegenerationExecution.abortController.abort();
}
specRegenerationExecution = null;
@@ -838,7 +1171,9 @@ ipcMain.handle("spec-regeneration:stop", async () => {
ipcMain.handle("spec-regeneration:status", () => {
return {
success: true,
isRunning: specRegenerationExecution !== null && specRegenerationExecution.isActive(),
isRunning:
specRegenerationExecution !== null &&
specRegenerationExecution.isActive(),
};
});
@@ -848,9 +1183,15 @@ ipcMain.handle("spec-regeneration:status", () => {
ipcMain.handle(
"spec-regeneration:create",
async (_, { projectPath, projectOverview, generateFeatures = true }) => {
console.log("[IPC] spec-regeneration:create called with:", { projectPath, generateFeatures });
console.log("[IPC] spec-regeneration:create called with:", {
projectPath,
generateFeatures,
});
try {
// Add project path to allowed paths
addAllowedPath(projectPath);
// Check if already running
if (specRegenerationExecution && specRegenerationExecution.isActive()) {
return { success: false, error: "Spec creation is already running" };
@@ -871,9 +1212,18 @@ ipcMain.handle(
// Start creating spec (runs in background)
specRegenerationService
.createInitialSpec(projectPath, projectOverview, sendToRenderer, specRegenerationExecution, generateFeatures)
.createInitialSpec(
projectPath,
projectOverview,
sendToRenderer,
specRegenerationExecution,
generateFeatures
)
.catch((error) => {
console.error("[IPC] spec-regeneration:create background error:", error);
console.error(
"[IPC] spec-regeneration:create background error:",
error
);
sendToRenderer({
type: "spec_regeneration_error",
error: error.message,
@@ -892,3 +1242,124 @@ ipcMain.handle(
}
}
);
/**
* Merge feature worktree changes back to main branch
*/
ipcMain.handle(
"worktree:merge-feature",
async (_, { projectPath, featureId, options }) => {
console.log("[IPC] worktree:merge-feature called with:", {
projectPath,
featureId,
options,
});
try {
const sendToRenderer = (data) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("auto-mode:event", data);
}
};
return await autoModeService.mergeFeature({
projectPath,
featureId,
options,
sendToRenderer,
});
} catch (error) {
console.error("[IPC] worktree:merge-feature error:", error);
return { success: false, error: error.message };
}
}
);
/**
* Get worktree info for a feature
*/
ipcMain.handle("worktree:get-info", async (_, { projectPath, featureId }) => {
try {
return await autoModeService.getWorktreeInfo({ projectPath, featureId });
} catch (error) {
console.error("[IPC] worktree:get-info error:", error);
return { success: false, error: error.message };
}
});
/**
* Get worktree status (changed files, commits)
*/
ipcMain.handle("worktree:get-status", async (_, { projectPath, featureId }) => {
try {
return await autoModeService.getWorktreeStatus({ projectPath, featureId });
} catch (error) {
console.error("[IPC] worktree:get-status error:", error);
return { success: false, error: error.message };
}
});
/**
* List all feature worktrees
*/
ipcMain.handle("worktree:list", async (_, { projectPath }) => {
try {
return await autoModeService.listWorktrees({ projectPath });
} catch (error) {
console.error("[IPC] worktree:list error:", error);
return { success: false, error: error.message };
}
});
/**
* Get file diffs for a worktree
*/
ipcMain.handle("worktree:get-diffs", async (_, { projectPath, featureId }) => {
try {
return await autoModeService.getFileDiffs({ projectPath, featureId });
} catch (error) {
console.error("[IPC] worktree:get-diffs error:", error);
return { success: false, error: error.message };
}
});
/**
* Get diff for a specific file in a worktree
*/
ipcMain.handle(
"worktree:get-file-diff",
async (_, { projectPath, featureId, filePath }) => {
try {
return await autoModeService.getFileDiff({
projectPath,
featureId,
filePath,
});
} catch (error) {
console.error("[IPC] worktree:get-file-diff error:", error);
return { success: false, error: error.message };
}
}
);
/**
* Get file diffs for the main project (non-worktree)
*/
ipcMain.handle("git:get-diffs", async (_, { projectPath }) => {
try {
return await worktreeManager.getFileDiffs(projectPath);
} catch (error) {
console.error("[IPC] git:get-diffs error:", error);
return { success: false, error: error.message };
}
});
/**
* Get diff for a specific file in the main project (non-worktree)
*/
ipcMain.handle("git:get-file-diff", async (_, { projectPath, filePath }) => {
try {
return await worktreeManager.getFileDiff(projectPath, filePath);
} catch (error) {
console.error("[IPC] git:get-file-diff error:", error);
return { success: false, error: error.message };
}
});

View File

@@ -97,8 +97,8 @@ contextBridge.exposeInMainWorld("electronAPI", {
status: () => ipcRenderer.invoke("auto-mode:status"),
// Run a specific feature
runFeature: (projectPath, featureId) =>
ipcRenderer.invoke("auto-mode:run-feature", { projectPath, featureId }),
runFeature: (projectPath, featureId, useWorktrees) =>
ipcRenderer.invoke("auto-mode:run-feature", { projectPath, featureId, useWorktrees }),
// Verify a specific feature by running its tests
verifyFeature: (projectPath, featureId) =>
@@ -140,6 +140,67 @@ contextBridge.exposeInMainWorld("electronAPI", {
},
},
// Claude CLI Detection API
checkClaudeCli: () => ipcRenderer.invoke("claude:check-cli"),
// Codex CLI Detection API
checkCodexCli: () => ipcRenderer.invoke("codex:check-cli"),
// Model Management APIs
model: {
// Get all available models from all providers
getAvailable: () => ipcRenderer.invoke("model:get-available"),
// Check all provider installation status
checkProviders: () => ipcRenderer.invoke("model:check-providers"),
},
// OpenAI API
testOpenAIConnection: (apiKey) =>
ipcRenderer.invoke("openai:test-connection", { apiKey }),
// Worktree Management APIs
worktree: {
// Revert feature changes by removing the worktree
revertFeature: (projectPath, featureId) =>
ipcRenderer.invoke("worktree:revert-feature", { projectPath, featureId }),
// Merge feature worktree changes back to main branch
mergeFeature: (projectPath, featureId, options) =>
ipcRenderer.invoke("worktree:merge-feature", { projectPath, featureId, options }),
// Get worktree info for a feature
getInfo: (projectPath, featureId) =>
ipcRenderer.invoke("worktree:get-info", { projectPath, featureId }),
// Get worktree status (changed files, commits)
getStatus: (projectPath, featureId) =>
ipcRenderer.invoke("worktree:get-status", { projectPath, featureId }),
// List all feature worktrees
list: (projectPath) =>
ipcRenderer.invoke("worktree:list", { projectPath }),
// Get file diffs for a feature worktree
getDiffs: (projectPath, featureId) =>
ipcRenderer.invoke("worktree:get-diffs", { projectPath, featureId }),
// Get diff for a specific file in a worktree
getFileDiff: (projectPath, featureId, filePath) =>
ipcRenderer.invoke("worktree:get-file-diff", { projectPath, featureId, filePath }),
},
// Git Operations APIs (for non-worktree operations)
git: {
// Get file diffs for the main project
getDiffs: (projectPath) =>
ipcRenderer.invoke("git:get-diffs", { projectPath }),
// Get diff for a specific file in the main project
getFileDiff: (projectPath, filePath) =>
ipcRenderer.invoke("git:get-file-diff", { projectPath, filePath }),
},
// Feature Suggestions API
suggestions: {
// Generate feature suggestions

View File

@@ -0,0 +1,119 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
class ClaudeCliDetector {
/**
* Check if Claude Code CLI is installed and accessible
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'sdk'|'none' }
*/
static detectClaudeInstallation() {
try {
// Method 1: Check if 'claude' command is in PATH
try {
const claudePath = execSync('which claude', { encoding: 'utf-8' }).trim();
const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
};
} catch (error) {
// CLI not in PATH, check local installation
}
// Method 2: Check for local installation
const localClaudePath = path.join(os.homedir(), '.claude', 'local', 'claude');
if (fs.existsSync(localClaudePath)) {
try {
const version = execSync(`${localClaudePath} --version`, { encoding: 'utf-8' }).trim();
return {
installed: true,
path: localClaudePath,
version: version,
method: 'cli-local'
};
} catch (error) {
// Local CLI exists but may not be executable
}
}
// Method 3: Check Windows path
if (process.platform === 'win32') {
try {
const claudePath = execSync('where claude', { encoding: 'utf-8' }).trim();
const version = execSync('claude --version', { encoding: 'utf-8' }).trim();
return {
installed: true,
path: claudePath,
version: version,
method: 'cli'
};
} catch (error) {
// Not found
}
}
// Method 4: SDK mode (using OAuth token)
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
return {
installed: true,
path: null,
version: 'SDK Mode',
method: 'sdk'
};
}
return {
installed: false,
path: null,
version: null,
method: 'none'
};
} catch (error) {
console.error('[ClaudeCliDetector] Error detecting Claude installation:', error);
return {
installed: false,
path: null,
version: null,
method: 'none',
error: error.message
};
}
}
/**
* Get installation recommendations
*/
static getInstallationInfo() {
const detection = this.detectClaudeInstallation();
if (detection.installed) {
return {
status: 'installed',
method: detection.method,
version: detection.version,
path: detection.path,
recommendation: detection.method === 'cli'
? 'Using Claude Code CLI - optimal for long-running tasks'
: 'Using SDK mode - works well but CLI may provide better performance'
};
}
return {
status: 'not_installed',
recommendation: 'Consider installing Claude Code CLI for better performance with ultrathink',
installCommands: {
macos: 'curl -fsSL claude.ai/install.sh | bash',
windows: 'irm https://claude.ai/install.ps1 | iex',
linux: 'curl -fsSL claude.ai/install.sh | bash',
npm: 'npm install -g @anthropic-ai/claude-code'
}
};
}
}
module.exports = ClaudeCliDetector;

View File

@@ -0,0 +1,229 @@
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
/**
* Codex CLI Detector - Checks if OpenAI Codex CLI is installed
*
* Codex CLI is OpenAI's agent CLI tool that allows users to use
* GPT-5.1 Codex models (gpt-5.1-codex-max, gpt-5.1-codex, etc.)
* for code generation and agentic tasks.
*/
class CodexCliDetector {
/**
* Check if Codex CLI is installed and accessible
* @returns {Object} { installed: boolean, path: string|null, version: string|null, method: 'cli'|'npm'|'brew'|'none' }
*/
static detectCodexInstallation() {
try {
// Method 1: Check if 'codex' command is in PATH
try {
const codexPath = execSync('which codex 2>/dev/null', { encoding: 'utf-8' }).trim();
if (codexPath) {
const version = this.getCodexVersion(codexPath);
return {
installed: true,
path: codexPath,
version: version,
method: 'cli'
};
}
} catch (error) {
// CLI not in PATH, continue checking other methods
}
// Method 2: Check for npm global installation
try {
const npmListOutput = execSync('npm list -g @openai/codex --depth=0 2>/dev/null', { encoding: 'utf-8' });
if (npmListOutput && npmListOutput.includes('@openai/codex')) {
// Get the path from npm bin
const npmBinPath = execSync('npm bin -g', { encoding: 'utf-8' }).trim();
const codexPath = path.join(npmBinPath, 'codex');
const version = this.getCodexVersion(codexPath);
return {
installed: true,
path: codexPath,
version: version,
method: 'npm'
};
}
} catch (error) {
// npm global not found
}
// Method 3: Check for Homebrew installation on macOS
if (process.platform === 'darwin') {
try {
const brewList = execSync('brew list --formula 2>/dev/null', { encoding: 'utf-8' });
if (brewList.includes('codex')) {
const brewPrefixOutput = execSync('brew --prefix codex 2>/dev/null', { encoding: 'utf-8' }).trim();
const codexPath = path.join(brewPrefixOutput, 'bin', 'codex');
const version = this.getCodexVersion(codexPath);
return {
installed: true,
path: codexPath,
version: version,
method: 'brew'
};
}
} catch (error) {
// Homebrew not found or codex not installed via brew
}
}
// Method 4: Check Windows path
if (process.platform === 'win32') {
try {
const codexPath = execSync('where codex 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
if (codexPath) {
const version = this.getCodexVersion(codexPath);
return {
installed: true,
path: codexPath,
version: version,
method: 'cli'
};
}
} catch (error) {
// Not found on Windows
}
}
// Method 5: Check common installation paths
const commonPaths = [
path.join(os.homedir(), '.local', 'bin', 'codex'),
path.join(os.homedir(), '.npm-global', 'bin', 'codex'),
'/usr/local/bin/codex',
'/opt/homebrew/bin/codex',
];
for (const checkPath of commonPaths) {
if (fs.existsSync(checkPath)) {
const version = this.getCodexVersion(checkPath);
return {
installed: true,
path: checkPath,
version: version,
method: 'cli'
};
}
}
// Method 6: Check if OPENAI_API_KEY is set (can use Codex API directly)
if (process.env.OPENAI_API_KEY) {
return {
installed: false,
path: null,
version: null,
method: 'api-key-only',
hasApiKey: true
};
}
return {
installed: false,
path: null,
version: null,
method: 'none'
};
} catch (error) {
console.error('[CodexCliDetector] Error detecting Codex installation:', error);
return {
installed: false,
path: null,
version: null,
method: 'none',
error: error.message
};
}
}
/**
* Get Codex CLI version from executable path
* @param {string} codexPath Path to codex executable
* @returns {string|null} Version string or null
*/
static getCodexVersion(codexPath) {
try {
const version = execSync(`"${codexPath}" --version 2>/dev/null`, { encoding: 'utf-8' }).trim();
return version || null;
} catch (error) {
return null;
}
}
/**
* Get installation info and recommendations
* @returns {Object} Installation status and recommendations
*/
static getInstallationInfo() {
const detection = this.detectCodexInstallation();
if (detection.installed) {
return {
status: 'installed',
method: detection.method,
version: detection.version,
path: detection.path,
recommendation: detection.method === 'cli'
? 'Using Codex CLI - ready for GPT-5.1 Codex models'
: `Using Codex CLI via ${detection.method} - ready for GPT-5.1 Codex models`
};
}
// Not installed but has API key
if (detection.method === 'api-key-only') {
return {
status: 'api_key_only',
method: 'api-key-only',
recommendation: 'OPENAI_API_KEY detected but Codex CLI not installed. Install Codex CLI for full agentic capabilities.',
installCommands: this.getInstallCommands()
};
}
return {
status: 'not_installed',
recommendation: 'Install OpenAI Codex CLI to use GPT-5.1 Codex models for agentic tasks',
installCommands: this.getInstallCommands()
};
}
/**
* Get installation commands for different platforms
* @returns {Object} Installation commands by platform
*/
static getInstallCommands() {
return {
npm: 'npm install -g @openai/codex@latest',
macos: 'brew install codex',
linux: 'npm install -g @openai/codex@latest',
windows: 'npm install -g @openai/codex@latest'
};
}
/**
* Check if Codex CLI supports a specific model
* @param {string} model Model name to check
* @returns {boolean} Whether the model is supported
*/
static isModelSupported(model) {
const supportedModels = [
'gpt-5.1-codex-max',
'gpt-5.1-codex',
'gpt-5.1-codex-mini',
'gpt-5.1'
];
return supportedModels.includes(model);
}
/**
* Get default model for Codex CLI
* @returns {string} Default model name
*/
static getDefaultModel() {
return 'gpt-5.1-codex-max';
}
}
module.exports = CodexCliDetector;

View File

@@ -0,0 +1,351 @@
/**
* Codex TOML Configuration Manager
*
* Manages Codex CLI's TOML configuration file to add/update MCP server settings.
* Codex CLI looks for config at:
* - ~/.codex/config.toml (user-level)
* - .codex/config.toml (project-level, takes precedence)
*/
const fs = require('fs/promises');
const path = require('path');
const os = require('os');
class CodexConfigManager {
constructor() {
this.userConfigPath = path.join(os.homedir(), '.codex', 'config.toml');
this.projectConfigPath = null; // Will be set per project
}
/**
* Set the project path for project-level config
*/
setProjectPath(projectPath) {
this.projectConfigPath = path.join(projectPath, '.codex', 'config.toml');
}
/**
* Get the effective config path (project-level if exists, otherwise user-level)
*/
async getConfigPath() {
if (this.projectConfigPath) {
try {
await fs.access(this.projectConfigPath);
return this.projectConfigPath;
} catch (e) {
// Project config doesn't exist, fall back to user config
}
}
// Ensure user config directory exists
const userConfigDir = path.dirname(this.userConfigPath);
try {
await fs.mkdir(userConfigDir, { recursive: true });
} catch (e) {
// Directory might already exist
}
return this.userConfigPath;
}
/**
* Read existing TOML config (simple parser for our needs)
*/
async readConfig(configPath) {
try {
const content = await fs.readFile(configPath, 'utf-8');
return this.parseToml(content);
} catch (e) {
if (e.code === 'ENOENT') {
return {};
}
throw e;
}
}
/**
* Simple TOML parser for our specific use case
* This is a minimal parser that handles the MCP server config structure
*/
parseToml(content) {
const config = {};
let currentSection = null;
let currentSubsection = null;
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Skip comments and empty lines
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
// Section header: [section]
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
const sectionName = sectionMatch[1];
const parts = sectionName.split('.');
if (parts.length === 1) {
currentSection = parts[0];
currentSubsection = null;
if (!config[currentSection]) {
config[currentSection] = {};
}
} else if (parts.length === 2) {
currentSection = parts[0];
currentSubsection = parts[1];
if (!config[currentSection]) {
config[currentSection] = {};
}
if (!config[currentSection][currentSubsection]) {
config[currentSection][currentSubsection] = {};
}
}
continue;
}
// Key-value pair: key = value
const kvMatch = trimmed.match(/^([^=]+)=(.+)$/);
if (kvMatch) {
const key = kvMatch[1].trim();
let value = kvMatch[2].trim();
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
// Parse boolean
if (value === 'true') value = true;
else if (value === 'false') value = false;
// Parse number
else if (/^-?\d+$/.test(value)) value = parseInt(value, 10);
else if (/^-?\d+\.\d+$/.test(value)) value = parseFloat(value);
if (currentSubsection) {
if (!config[currentSection][currentSubsection]) {
config[currentSection][currentSubsection] = {};
}
config[currentSection][currentSubsection][key] = value;
} else if (currentSection) {
if (!config[currentSection]) {
config[currentSection] = {};
}
config[currentSection][key] = value;
} else {
config[key] = value;
}
}
}
return config;
}
/**
* Convert config object back to TOML format
*/
stringifyToml(config, indent = 0) {
const indentStr = ' '.repeat(indent);
let result = '';
for (const [key, value] of Object.entries(config)) {
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
// Section
result += `${indentStr}[${key}]\n`;
result += this.stringifyToml(value, indent);
} else {
// Key-value
let valueStr = value;
if (typeof value === 'string') {
// Escape quotes and wrap in quotes if needed
if (value.includes('"') || value.includes("'") || value.includes(' ')) {
valueStr = `"${value.replace(/"/g, '\\"')}"`;
}
} else if (typeof value === 'boolean') {
valueStr = value.toString();
}
result += `${indentStr}${key} = ${valueStr}\n`;
}
}
return result;
}
/**
* Configure the automaker-tools MCP server
*/
async configureMcpServer(projectPath, mcpServerScriptPath) {
this.setProjectPath(projectPath);
const configPath = await this.getConfigPath();
// Read existing config
const config = await this.readConfig(configPath);
// Ensure mcp_servers section exists
if (!config.mcp_servers) {
config.mcp_servers = {};
}
// Configure automaker-tools server
config.mcp_servers['automaker-tools'] = {
command: 'node',
args: [mcpServerScriptPath],
env: {
AUTOMAKER_PROJECT_PATH: projectPath
},
startup_timeout_sec: 10,
tool_timeout_sec: 60,
enabled_tools: ['UpdateFeatureStatus']
};
// Ensure experimental_use_rmcp_client is enabled (if needed)
if (!config.experimental_use_rmcp_client) {
config.experimental_use_rmcp_client = true;
}
// Write config back
await this.writeConfig(configPath, config);
console.log(`[CodexConfigManager] Configured automaker-tools MCP server in ${configPath}`);
return configPath;
}
/**
* Write config to TOML file
*/
async writeConfig(configPath, config) {
let content = '';
// Write top-level keys first (preserve existing non-MCP config)
for (const [key, value] of Object.entries(config)) {
if (key === 'mcp_servers' || key === 'experimental_use_rmcp_client') {
continue; // Handle these separately
}
if (typeof value !== 'object') {
content += `${key} = ${this.formatValue(value)}\n`;
}
}
// Write experimental flag if enabled
if (config.experimental_use_rmcp_client) {
if (content && !content.endsWith('\n\n')) {
content += '\n';
}
content += `experimental_use_rmcp_client = true\n`;
}
// Write mcp_servers section
if (config.mcp_servers && Object.keys(config.mcp_servers).length > 0) {
if (content && !content.endsWith('\n\n')) {
content += '\n';
}
for (const [serverName, serverConfig] of Object.entries(config.mcp_servers)) {
content += `\n[mcp_servers.${serverName}]\n`;
// Write command first
if (serverConfig.command) {
content += `command = "${this.escapeTomlString(serverConfig.command)}"\n`;
}
// Write args
if (serverConfig.args && Array.isArray(serverConfig.args)) {
const argsStr = serverConfig.args.map(a => `"${this.escapeTomlString(a)}"`).join(', ');
content += `args = [${argsStr}]\n`;
}
// Write timeouts (must be before env subsection)
if (serverConfig.startup_timeout_sec !== undefined) {
content += `startup_timeout_sec = ${serverConfig.startup_timeout_sec}\n`;
}
if (serverConfig.tool_timeout_sec !== undefined) {
content += `tool_timeout_sec = ${serverConfig.tool_timeout_sec}\n`;
}
// Write enabled_tools (must be before env subsection - at server level, not env level)
if (serverConfig.enabled_tools && Array.isArray(serverConfig.enabled_tools)) {
const toolsStr = serverConfig.enabled_tools.map(t => `"${this.escapeTomlString(t)}"`).join(', ');
content += `enabled_tools = [${toolsStr}]\n`;
}
// Write env section last (as a separate subsection)
// IMPORTANT: In TOML, once we start [mcp_servers.server_name.env],
// everything after belongs to that subsection until a new section starts
if (serverConfig.env && typeof serverConfig.env === 'object' && Object.keys(serverConfig.env).length > 0) {
content += `\n[mcp_servers.${serverName}.env]\n`;
for (const [envKey, envValue] of Object.entries(serverConfig.env)) {
content += `${envKey} = "${this.escapeTomlString(String(envValue))}"\n`;
}
}
}
}
// Ensure directory exists
const configDir = path.dirname(configPath);
await fs.mkdir(configDir, { recursive: true });
// Write file
await fs.writeFile(configPath, content, 'utf-8');
}
/**
* Escape special characters in TOML strings
*/
escapeTomlString(str) {
return str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t');
}
/**
* Format a value for TOML output
*/
formatValue(value) {
if (typeof value === 'string') {
// Escape quotes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
return `"${escaped}"`;
} else if (typeof value === 'boolean') {
return value.toString();
} else if (typeof value === 'number') {
return value.toString();
}
return `"${String(value)}"`;
}
/**
* Remove automaker-tools MCP server configuration
*/
async removeMcpServer(projectPath) {
this.setProjectPath(projectPath);
const configPath = await this.getConfigPath();
try {
const config = await this.readConfig(configPath);
if (config.mcp_servers && config.mcp_servers['automaker-tools']) {
delete config.mcp_servers['automaker-tools'];
// If no more MCP servers, remove the section
if (Object.keys(config.mcp_servers).length === 0) {
delete config.mcp_servers;
}
await this.writeConfig(configPath, config);
console.log(`[CodexConfigManager] Removed automaker-tools MCP server from ${configPath}`);
}
} catch (e) {
console.error(`[CodexConfigManager] Error removing MCP server config:`, e);
}
}
}
module.exports = new CodexConfigManager();

View File

@@ -0,0 +1,610 @@
/**
* Codex CLI Execution Wrapper
*
* This module handles spawning and managing Codex CLI processes
* for executing OpenAI model queries.
*/
const { spawn } = require('child_process');
const { EventEmitter } = require('events');
const readline = require('readline');
const path = require('path');
const CodexCliDetector = require('./codex-cli-detector');
const codexConfigManager = require('./codex-config-manager');
/**
* Message types from Codex CLI JSON output
*/
const CODEX_EVENT_TYPES = {
THREAD_STARTED: 'thread.started',
ITEM_STARTED: 'item.started',
ITEM_COMPLETED: 'item.completed',
THREAD_COMPLETED: 'thread.completed',
ERROR: 'error'
};
/**
* Codex Executor - Manages Codex CLI process execution
*/
class CodexExecutor extends EventEmitter {
constructor() {
super();
this.currentProcess = null;
this.codexPath = null;
}
/**
* Find and cache the Codex CLI path
* @returns {string|null} Path to codex executable
*/
findCodexPath() {
if (this.codexPath) {
return this.codexPath;
}
const installation = CodexCliDetector.detectCodexInstallation();
if (installation.installed && installation.path) {
this.codexPath = installation.path;
return this.codexPath;
}
return null;
}
/**
* Execute a Codex CLI query
* @param {Object} options Execution options
* @param {string} options.prompt The prompt to execute
* @param {string} options.model Model to use (default: gpt-5.1-codex-max)
* @param {string} options.cwd Working directory
* @param {string} options.systemPrompt System prompt (optional, will be prepended to prompt)
* @param {number} options.maxTurns Not used - Codex CLI doesn't support this parameter
* @param {string[]} options.allowedTools Not used - Codex CLI doesn't support this parameter
* @param {Object} options.env Environment variables
* @param {Object} options.mcpServers MCP servers configuration (for configuring Codex TOML)
* @returns {AsyncGenerator} Generator yielding messages
*/
async *execute(options) {
const {
prompt,
model = 'gpt-5.1-codex-max',
cwd = process.cwd(),
systemPrompt,
maxTurns, // Not used by Codex CLI
allowedTools, // Not used by Codex CLI
env = {},
mcpServers = null
} = options;
const codexPath = this.findCodexPath();
if (!codexPath) {
yield {
type: 'error',
error: 'Codex CLI not found. Please install it with: npm install -g @openai/codex@latest'
};
return;
}
// Configure MCP server if provided
if (mcpServers && mcpServers['automaker-tools']) {
try {
// Get the absolute path to the MCP server script
const mcpServerScriptPath = path.resolve(__dirname, 'mcp-server-stdio.js');
// Verify the script exists
const fs = require('fs');
if (!fs.existsSync(mcpServerScriptPath)) {
console.warn(`[CodexExecutor] MCP server script not found at ${mcpServerScriptPath}, skipping MCP configuration`);
} else {
// Configure Codex TOML to use the MCP server
await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath);
console.log('[CodexExecutor] Configured automaker-tools MCP server for Codex CLI');
}
} catch (error) {
console.error('[CodexExecutor] Failed to configure MCP server:', error);
// Continue execution even if MCP config fails - Codex will work without MCP tools
}
}
// Combine system prompt with main prompt if provided
// Codex CLI doesn't support --system-prompt argument, so we prepend it to the prompt
let combinedPrompt = prompt;
console.log('[CodexExecutor] Original prompt length:', prompt?.length || 0);
if (systemPrompt) {
combinedPrompt = `${systemPrompt}\n\n---\n\n${prompt}`;
console.log('[CodexExecutor] System prompt prepended to main prompt');
console.log('[CodexExecutor] System prompt length:', systemPrompt.length);
console.log('[CodexExecutor] Combined prompt length:', combinedPrompt.length);
}
// Build command arguments
// Note: maxTurns and allowedTools are not supported by Codex CLI
console.log('[CodexExecutor] Building command arguments...');
const args = this.buildArgs({
prompt: combinedPrompt,
model
});
console.log('[CodexExecutor] Executing command:', codexPath);
console.log('[CodexExecutor] Number of args:', args.length);
console.log('[CodexExecutor] Args (without prompt):', args.slice(0, -1).join(' '));
console.log('[CodexExecutor] Prompt length in args:', args[args.length - 1]?.length || 0);
console.log('[CodexExecutor] Prompt preview (first 200 chars):', args[args.length - 1]?.substring(0, 200));
console.log('[CodexExecutor] Working directory:', cwd);
// Spawn the process
const processEnv = {
...process.env,
...env,
// Ensure OPENAI_API_KEY is available
OPENAI_API_KEY: env.OPENAI_API_KEY || process.env.OPENAI_API_KEY
};
// Log API key status (without exposing the key)
if (processEnv.OPENAI_API_KEY) {
console.log('[CodexExecutor] OPENAI_API_KEY is set (length:', processEnv.OPENAI_API_KEY.length, ')');
} else {
console.warn('[CodexExecutor] WARNING: OPENAI_API_KEY is not set!');
}
console.log('[CodexExecutor] Spawning process...');
const proc = spawn(codexPath, args, {
cwd,
env: processEnv,
stdio: ['pipe', 'pipe', 'pipe']
});
this.currentProcess = proc;
console.log('[CodexExecutor] Process spawned with PID:', proc.pid);
// Track process events
proc.on('error', (error) => {
console.error('[CodexExecutor] Process error:', error);
});
proc.on('spawn', () => {
console.log('[CodexExecutor] Process spawned successfully');
});
// Collect stderr output as it comes in
let stderr = '';
let hasOutput = false;
let stdoutChunks = [];
let stderrChunks = [];
proc.stderr.on('data', (data) => {
const errorText = data.toString();
stderr += errorText;
stderrChunks.push(errorText);
hasOutput = true;
console.error('[CodexExecutor] stderr chunk received (', data.length, 'bytes):', errorText.substring(0, 200));
});
proc.stderr.on('end', () => {
console.log('[CodexExecutor] stderr stream ended. Total chunks:', stderrChunks.length, 'Total length:', stderr.length);
});
proc.stdout.on('data', (data) => {
const text = data.toString();
stdoutChunks.push(text);
hasOutput = true;
console.log('[CodexExecutor] stdout chunk received (', data.length, 'bytes):', text.substring(0, 200));
});
proc.stdout.on('end', () => {
console.log('[CodexExecutor] stdout stream ended. Total chunks:', stdoutChunks.length);
});
// Create readline interface for parsing JSONL output
console.log('[CodexExecutor] Creating readline interface...');
const rl = readline.createInterface({
input: proc.stdout,
crlfDelay: Infinity
});
// Track accumulated content for converting to Claude format
let accumulatedText = '';
let toolUses = [];
let lastOutputTime = Date.now();
const OUTPUT_TIMEOUT = 30000; // 30 seconds timeout for no output
let lineCount = 0;
let jsonParseErrors = 0;
// Set up timeout check
const checkTimeout = setInterval(() => {
const timeSinceLastOutput = Date.now() - lastOutputTime;
if (timeSinceLastOutput > OUTPUT_TIMEOUT && !hasOutput) {
console.warn('[CodexExecutor] No output received for', timeSinceLastOutput, 'ms. Process still alive:', !proc.killed);
}
}, 5000);
console.log('[CodexExecutor] Starting to read lines from stdout...');
// Process stdout line by line (JSONL format)
try {
for await (const line of rl) {
hasOutput = true;
lastOutputTime = Date.now();
lineCount++;
console.log('[CodexExecutor] Line', lineCount, 'received (length:', line.length, '):', line.substring(0, 100));
if (!line.trim()) {
console.log('[CodexExecutor] Skipping empty line');
continue;
}
try {
const event = JSON.parse(line);
console.log('[CodexExecutor] Successfully parsed JSON event. Type:', event.type, 'Keys:', Object.keys(event));
const convertedMsg = this.convertToClaudeFormat(event);
console.log('[CodexExecutor] Converted message:', convertedMsg ? { type: convertedMsg.type } : 'null');
if (convertedMsg) {
// Accumulate text content
if (convertedMsg.type === 'assistant' && convertedMsg.message?.content) {
for (const block of convertedMsg.message.content) {
if (block.type === 'text') {
accumulatedText += block.text;
console.log('[CodexExecutor] Accumulated text block (total length:', accumulatedText.length, ')');
} else if (block.type === 'tool_use') {
toolUses.push(block);
console.log('[CodexExecutor] Tool use detected:', block.name);
}
}
}
console.log('[CodexExecutor] Yielding message of type:', convertedMsg.type);
yield convertedMsg;
} else {
console.log('[CodexExecutor] Converted message is null, skipping');
}
} catch (parseError) {
jsonParseErrors++;
// Non-JSON output, yield as text
console.log('[CodexExecutor] JSON parse error (', jsonParseErrors, 'total):', parseError.message);
console.log('[CodexExecutor] Non-JSON line content:', line.substring(0, 200));
yield {
type: 'assistant',
message: {
content: [{ type: 'text', text: line + '\n' }]
}
};
}
}
console.log('[CodexExecutor] Finished reading all lines. Total lines:', lineCount, 'JSON errors:', jsonParseErrors);
} catch (readError) {
console.error('[CodexExecutor] Error reading from readline:', readError);
throw readError;
} finally {
clearInterval(checkTimeout);
console.log('[CodexExecutor] Cleaned up timeout checker');
}
// Handle process completion
console.log('[CodexExecutor] Waiting for process to close...');
const exitCode = await new Promise((resolve) => {
proc.on('close', (code, signal) => {
console.log('[CodexExecutor] Process closed with code:', code, 'signal:', signal);
resolve(code);
});
});
this.currentProcess = null;
console.log('[CodexExecutor] Process completed. Exit code:', exitCode, 'Has output:', hasOutput, 'Stderr length:', stderr.length);
// Wait a bit for any remaining stderr data to be collected
console.log('[CodexExecutor] Waiting 200ms for any remaining stderr data...');
await new Promise(resolve => setTimeout(resolve, 200));
console.log('[CodexExecutor] Final stderr length:', stderr.length, 'Final stdout chunks:', stdoutChunks.length);
if (exitCode !== 0) {
const errorMessage = stderr.trim()
? `Codex CLI exited with code ${exitCode}.\n\nError output:\n${stderr}`
: `Codex CLI exited with code ${exitCode}. No error output captured.`;
console.error('[CodexExecutor] Process failed with exit code', exitCode);
console.error('[CodexExecutor] Error message:', errorMessage);
console.error('[CodexExecutor] Stderr chunks:', stderrChunks.length, 'Stdout chunks:', stdoutChunks.length);
yield {
type: 'error',
error: errorMessage
};
} else if (!hasOutput && !stderr) {
// Process exited successfully but produced no output - might be API key issue
const warningMessage = 'Codex CLI completed but produced no output. This might indicate:\n' +
'- Missing or invalid OPENAI_API_KEY\n' +
'- Codex CLI configuration issue\n' +
'- The process completed without generating any response\n\n' +
`Debug info: Exit code ${exitCode}, stdout chunks: ${stdoutChunks.length}, stderr chunks: ${stderrChunks.length}, lines read: ${lineCount}`;
console.warn('[CodexExecutor] No output detected:', warningMessage);
console.warn('[CodexExecutor] Stdout chunks:', stdoutChunks);
console.warn('[CodexExecutor] Stderr chunks:', stderrChunks);
yield {
type: 'error',
error: warningMessage
};
} else {
console.log('[CodexExecutor] Process completed successfully. Exit code:', exitCode, 'Lines processed:', lineCount);
}
}
/**
* Build command arguments for Codex CLI
* Only includes supported arguments based on Codex CLI help:
* - --model: Model to use
* - --json: JSON output format
* - --full-auto: Non-interactive automatic execution
*
* Note: Codex CLI does NOT support:
* - --system-prompt (system prompt is prepended to main prompt)
* - --max-turns (not available in CLI)
* - --tools (not available in CLI)
*
* @param {Object} options Options
* @returns {string[]} Command arguments
*/
buildArgs(options) {
const { prompt, model } = options;
console.log('[CodexExecutor] buildArgs called with model:', model, 'prompt length:', prompt?.length || 0);
const args = ['exec'];
// Add model (required for most use cases)
if (model) {
args.push('--model', model);
console.log('[CodexExecutor] Added model argument:', model);
}
// Add JSON output flag for structured parsing
args.push('--json');
console.log('[CodexExecutor] Added --json flag');
// Add full-auto mode (non-interactive)
// This enables automatic execution with workspace-write sandbox
args.push('--full-auto');
console.log('[CodexExecutor] Added --full-auto flag');
// Add the prompt at the end
args.push(prompt);
console.log('[CodexExecutor] Added prompt (length:', prompt?.length || 0, ')');
console.log('[CodexExecutor] Final args count:', args.length);
return args;
}
/**
* Map Claude tool names to Codex tool names
* @param {string[]} tools Array of tool names
* @returns {string[]} Mapped tool names
*/
mapToolsToCodex(tools) {
const toolMap = {
'Read': 'read',
'Write': 'write',
'Edit': 'edit',
'Bash': 'bash',
'Glob': 'glob',
'Grep': 'grep',
'WebSearch': 'web-search',
'WebFetch': 'web-fetch'
};
return tools
.map(tool => toolMap[tool] || tool.toLowerCase())
.filter(tool => tool); // Remove undefined
}
/**
* Convert Codex JSONL event to Claude SDK message format
* @param {Object} event Codex event object
* @returns {Object|null} Claude-format message or null
*/
convertToClaudeFormat(event) {
console.log('[CodexExecutor] Converting event:', JSON.stringify(event).substring(0, 200));
const { type, data, item, thread_id } = event;
switch (type) {
case CODEX_EVENT_TYPES.THREAD_STARTED:
case 'thread.started':
// Session initialization
return {
type: 'session_start',
sessionId: thread_id || data?.thread_id || event.thread_id
};
case CODEX_EVENT_TYPES.ITEM_COMPLETED:
case 'item.completed':
// Codex uses 'item' field, not 'data'
return this.convertItemCompleted(item || data);
case CODEX_EVENT_TYPES.ITEM_STARTED:
case 'item.started':
// Convert item.started events - these indicate tool/command usage
const startedItem = item || data;
if (startedItem?.type === 'command_execution' && startedItem?.command) {
return {
type: 'assistant',
message: {
content: [{
type: 'tool_use',
name: 'bash',
input: { command: startedItem.command }
}]
}
};
}
// For other item.started types, return null (we'll show the completed version)
return null;
case CODEX_EVENT_TYPES.THREAD_COMPLETED:
case 'thread.completed':
return {
type: 'complete',
sessionId: thread_id || data?.thread_id || event.thread_id
};
case CODEX_EVENT_TYPES.ERROR:
case 'error':
return {
type: 'error',
error: data?.message || item?.message || event.message || 'Unknown error from Codex CLI'
};
case 'turn.started':
// Turn started - just a marker, no need to convert
return null;
default:
// Pass through other events
console.log('[CodexExecutor] Unhandled event type:', type);
return null;
}
}
/**
* Convert item.completed event to Claude format
* @param {Object} item Event item data
* @returns {Object|null} Claude-format message
*/
convertItemCompleted(item) {
if (!item) {
console.log('[CodexExecutor] convertItemCompleted: item is null/undefined');
return null;
}
const itemType = item.type || item.item_type;
console.log('[CodexExecutor] convertItemCompleted: itemType =', itemType, 'item keys:', Object.keys(item));
switch (itemType) {
case 'reasoning':
// Thinking/reasoning output - Codex uses 'text' field
const reasoningText = item.text || item.content || '';
console.log('[CodexExecutor] Converting reasoning, text length:', reasoningText.length);
return {
type: 'assistant',
message: {
content: [{
type: 'thinking',
thinking: reasoningText
}]
}
};
case 'agent_message':
case 'message':
// Assistant text message
const messageText = item.content || item.text || '';
console.log('[CodexExecutor] Converting message, text length:', messageText.length);
return {
type: 'assistant',
message: {
content: [{
type: 'text',
text: messageText
}]
}
};
case 'command_execution':
// Command execution - show both the command and its output
const command = item.command || '';
const output = item.aggregated_output || item.output || '';
console.log('[CodexExecutor] Converting command_execution, command:', command.substring(0, 50), 'output length:', output.length);
// Return as text message showing the command and output
return {
type: 'assistant',
message: {
content: [{
type: 'text',
text: `\`\`\`bash\n${command}\n\`\`\`\n\n${output}`
}]
}
};
case 'tool_use':
// Tool use
return {
type: 'assistant',
message: {
content: [{
type: 'tool_use',
name: item.tool || item.command || 'unknown',
input: item.input || item.args || {}
}]
}
};
case 'tool_result':
// Tool result
return {
type: 'tool_result',
tool_use_id: item.tool_use_id,
content: item.output || item.result
};
case 'todo_list':
// Todo list - convert to text format
const todos = item.items || [];
const todoText = todos.map((t, i) => `${i + 1}. ${t.text || t}`).join('\n');
console.log('[CodexExecutor] Converting todo_list, items:', todos.length);
return {
type: 'assistant',
message: {
content: [{
type: 'text',
text: `**Todo List:**\n${todoText}`
}]
}
};
default:
// Generic text output
const text = item.text || item.content || item.aggregated_output;
if (text) {
console.log('[CodexExecutor] Converting default item type, text length:', text.length);
return {
type: 'assistant',
message: {
content: [{
type: 'text',
text: String(text)
}]
}
};
}
console.log('[CodexExecutor] convertItemCompleted: No text content found, returning null');
return null;
}
}
/**
* Abort current execution
*/
abort() {
if (this.currentProcess) {
console.log('[CodexExecutor] Aborting current process');
this.currentProcess.kill('SIGTERM');
this.currentProcess = null;
}
}
/**
* Check if execution is in progress
* @returns {boolean} Whether execution is in progress
*/
isRunning() {
return this.currentProcess !== null;
}
}
// Singleton instance
const codexExecutor = new CodexExecutor();
module.exports = codexExecutor;

View File

@@ -3,11 +3,176 @@ const promptBuilder = require("./prompt-builder");
const contextManager = require("./context-manager");
const featureLoader = require("./feature-loader");
const mcpServerFactory = require("./mcp-server-factory");
const { ModelRegistry } = require("./model-registry");
const { ModelProviderFactory } = require("./model-provider");
// Model name mappings for Claude (legacy - kept for backwards compatibility)
const MODEL_MAP = {
haiku: "claude-haiku-4-5",
sonnet: "claude-sonnet-4-20250514",
opus: "claude-opus-4-5-20251101",
};
// Thinking level to budget_tokens mapping
// These values control how much "thinking time" the model gets for extended thinking
const THINKING_BUDGET_MAP = {
none: null, // No extended thinking
low: 4096, // Light thinking
medium: 16384, // Moderate thinking
high: 65536, // Deep thinking
ultrathink: 262144, // Ultra-deep thinking (maximum reasoning)
};
/**
* Feature Executor - Handles feature implementation using Claude Agent SDK
* Now supports multiple model providers (Claude, Codex/OpenAI)
*/
class FeatureExecutor {
/**
* Get the model string based on feature's model setting
* Supports both Claude and Codex/OpenAI models
*/
getModelString(feature) {
const modelKey = feature.model || "opus"; // Default to opus
// First check if this is a Codex model - they use the model key directly as the string
if (ModelRegistry.isCodexModel(modelKey)) {
const model = ModelRegistry.getModel(modelKey);
if (model && model.modelString) {
console.log(
`[FeatureExecutor] getModelString: modelKey=${modelKey}, modelString=${model.modelString} (Codex model)`
);
return model.modelString;
}
// If model exists in registry but somehow no modelString, use the key itself
console.log(
`[FeatureExecutor] getModelString: modelKey=${modelKey}, modelString=${modelKey} (Codex fallback)`
);
return modelKey;
}
// For Claude models, use the registry lookup
let modelString = ModelRegistry.getModelString(modelKey);
// Fallback to MODEL_MAP if registry doesn't have it (legacy support)
if (!modelString) {
modelString = MODEL_MAP[modelKey];
}
// Final fallback to opus for Claude models only
if (!modelString) {
modelString = MODEL_MAP.opus;
}
// Validate model string format - ensure it's not incorrectly constructed
// Prevent incorrect formats like "claude-haiku-4-20250514" (mixing haiku with sonnet date)
if (modelString.includes("haiku") && modelString.includes("20250514")) {
console.error(
`[FeatureExecutor] Invalid model string detected: ${modelString}, using correct format`
);
modelString = MODEL_MAP.haiku || "claude-haiku-4-5";
}
console.log(
`[FeatureExecutor] getModelString: modelKey=${modelKey}, modelString=${modelString}`
);
return modelString;
}
/**
* Determine if the feature uses a Codex/OpenAI model
*/
isCodexModel(feature) {
const modelKey = feature.model || "opus";
return ModelRegistry.isCodexModel(modelKey);
}
/**
* Get the appropriate provider for the feature's model
*/
getProvider(feature) {
const modelKey = feature.model || "opus";
return ModelProviderFactory.getProviderForModel(modelKey);
}
/**
* Get thinking configuration based on feature's thinkingLevel
*/
getThinkingConfig(feature) {
const modelId = feature.model || "opus";
// Skip thinking config for models that don't support it (e.g., Codex CLI)
if (!ModelRegistry.modelSupportsThinking(modelId)) {
return null;
}
const level = feature.thinkingLevel || "none";
const budgetTokens = THINKING_BUDGET_MAP[level];
if (budgetTokens === null) {
return null; // No extended thinking
}
return {
type: "enabled",
budget_tokens: budgetTokens,
};
}
/**
* Prepare for ultrathink execution - validate and warn
*/
prepareForUltrathink(feature, thinkingConfig) {
if (feature.thinkingLevel !== "ultrathink") {
return { ready: true };
}
const warnings = [];
const recommendations = [];
// Check CLI installation
const claudeCliDetector = require("./claude-cli-detector");
const cliInfo = claudeCliDetector.getInstallationInfo();
if (cliInfo.status === "not_installed") {
warnings.push(
"Claude Code CLI not detected - ultrathink may have timeout issues"
);
recommendations.push(
"Install Claude Code CLI for optimal ultrathink performance"
);
}
// Validate budget tokens
if (thinkingConfig && thinkingConfig.budget_tokens > 32000) {
warnings.push(
`Ultrathink budget (${thinkingConfig.budget_tokens} tokens) exceeds recommended 32K - may cause long-running requests`
);
recommendations.push(
"Consider using batch processing for budgets above 32K"
);
}
// Cost estimate (rough)
const estimatedCost = ((thinkingConfig?.budget_tokens || 0) / 1000) * 0.015; // Rough estimate
if (estimatedCost > 1.0) {
warnings.push(
`Estimated cost: ~$${estimatedCost.toFixed(2)} per execution`
);
}
// Time estimate
warnings.push("Ultrathink tasks typically take 45-180 seconds");
return {
ready: true,
warnings,
recommendations,
estimatedCost,
estimatedTime: "45-180 seconds",
cliInfo,
};
}
/**
* Sleep helper
*/
@@ -22,6 +187,11 @@ class FeatureExecutor {
async implementFeature(feature, projectPath, sendToRenderer, execution) {
console.log(`[FeatureExecutor] Implementing: ${feature.description}`);
// Declare variables outside try block so they're available in catch
let modelString;
let providerName;
let isCodex;
try {
// ========================================
// PHASE 1: PLANNING
@@ -52,13 +222,59 @@ class FeatureExecutor {
projectPath
);
// Determine if we're in TDD mode (skipTests=false means TDD mode)
const isTDD = !feature.skipTests;
// Ensure feature has a model set (for backward compatibility with old features)
if (!feature.model) {
console.warn(
`[FeatureExecutor] Feature ${feature.id} missing model property, defaulting to 'opus'`
);
feature.model = "opus";
}
// Get model and thinking configuration from feature settings
const modelString = this.getModelString(feature);
const thinkingConfig = this.getThinkingConfig(feature);
// Prepare for ultrathink if needed
if (feature.thinkingLevel === "ultrathink") {
const preparation = this.prepareForUltrathink(feature, thinkingConfig);
console.log(`[FeatureExecutor] Ultrathink preparation:`, preparation);
// Log warnings
if (preparation.warnings && preparation.warnings.length > 0) {
preparation.warnings.forEach((warning) => {
console.warn(`[FeatureExecutor] ⚠️ ${warning}`);
});
}
// Send preparation info to renderer
sendToRenderer({
type: "auto_mode_ultrathink_preparation",
featureId: feature.id,
warnings: preparation.warnings || [],
recommendations: preparation.recommendations || [],
estimatedCost: preparation.estimatedCost,
estimatedTime: preparation.estimatedTime,
});
}
providerName = this.isCodexModel(feature) ? "Codex/OpenAI" : "Claude";
console.log(
`[FeatureExecutor] Using provider: ${providerName}, model: ${modelString}, thinking: ${
feature.thinkingLevel || "none"
}`
);
// Note: Claude Agent SDK handles authentication automatically - it can use:
// 1. CLAUDE_CODE_OAUTH_TOKEN env var (for SDK mode)
// 2. Claude CLI's own authentication (if CLI is installed)
// 3. ANTHROPIC_API_KEY (fallback)
// We don't need to validate here - let the SDK/CLI handle auth errors
// Configure options for the SDK query
const options = {
model: "claude-opus-4-5-20251101",
systemPrompt: await promptBuilder.getCodingPrompt(projectPath, isTDD),
model: modelString,
systemPrompt: promptBuilder.getCodingPrompt(),
maxTurns: 1000,
cwd: projectPath,
mcpServers: {
@@ -83,6 +299,11 @@ class FeatureExecutor {
abortController: abortController,
};
// Add thinking configuration if enabled
if (thinkingConfig) {
options.thinking = thinkingConfig;
}
// Build the prompt for this specific feature
let prompt = await promptBuilder.buildFeaturePrompt(feature, projectPath);
@@ -135,8 +356,18 @@ class FeatureExecutor {
}
}
// Use content blocks instead of plain text
prompt = contentBlocks;
// Wrap content blocks in async generator for SDK (required format for multimodal prompts)
prompt = (async function* () {
yield {
type: "user",
session_id: "",
message: {
role: "user",
content: contentBlocks,
},
parent_tool_use_id: null,
};
})();
}
// Planning: Analyze the codebase and create implementation plan
@@ -168,8 +399,85 @@ class FeatureExecutor {
});
console.log(`[FeatureExecutor] Phase: ACTION for ${feature.description}`);
// Send query
const currentQuery = query({ prompt, options });
// Send query - use appropriate provider based on model
let currentQuery;
isCodex = this.isCodexModel(feature);
// Ensure provider auth is available (especially for Claude SDK)
const provider = this.getProvider(feature);
if (provider?.ensureAuthEnv && !provider.ensureAuthEnv()) {
// Check if CLI is installed to provide better error message
let authMsg =
"Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.";
try {
const claudeCliDetector = require("./claude-cli-detector");
const detection = claudeCliDetector.detectClaudeInstallation();
if (detection.installed && detection.method === "cli") {
authMsg =
"Claude CLI is installed but not authenticated. Run `claude login` to authenticate, or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.";
} else {
authMsg =
"Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`.";
}
} catch (err) {
// Fallback to default message
}
console.error(`[FeatureExecutor] ${authMsg}`);
throw new Error(authMsg);
}
// Validate that model string matches the provider
if (isCodex) {
// Ensure model string is actually a Codex model, not a Claude model
if (modelString.startsWith("claude-")) {
console.error(
`[FeatureExecutor] ERROR: Codex provider selected but Claude model string detected: ${modelString}`
);
console.error(
`[FeatureExecutor] Feature model: ${
feature.model || "not set"
}, modelString: ${modelString}`
);
throw new Error(
`Invalid model configuration: Codex provider cannot use Claude model '${modelString}'. Please check feature model setting.`
);
}
// Use Codex provider for OpenAI models
console.log(
`[FeatureExecutor] Using Codex provider for model: ${modelString}`
);
// Pass MCP server config to Codex provider so it can configure Codex CLI TOML
currentQuery = provider.executeQuery({
prompt,
model: modelString,
cwd: projectPath,
systemPrompt: promptBuilder.getCodingPrompt(),
maxTurns: 20, // Codex CLI typically uses fewer turns
allowedTools: options.allowedTools,
mcpServers: {
"automaker-tools": featureToolsServer,
},
abortController: abortController,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
},
});
} else {
// Ensure model string is actually a Claude model, not a Codex model
if (
!modelString.startsWith("claude-") &&
!modelString.match(/^(gpt-|o\d)/)
) {
console.warn(
`[FeatureExecutor] WARNING: Claude provider selected but unexpected model string: ${modelString}`
);
}
// Use Claude SDK (original implementation)
currentQuery = query({ prompt, options });
}
execution.query = currentQuery;
// Stream responses
@@ -179,6 +487,22 @@ class FeatureExecutor {
// Check if this specific feature was aborted
if (!execution.isActive()) break;
// Handle error messages
if (msg.type === "error") {
const errorMsg = `\n❌ Error: ${msg.error}\n`;
await contextManager.writeToContextFile(
projectPath,
feature.id,
errorMsg
);
sendToRenderer({
type: "auto_mode_error",
featureId: feature.id,
error: msg.error,
});
throw new Error(msg.error);
}
if (msg.type === "assistant" && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === "text") {
@@ -197,6 +521,22 @@ class FeatureExecutor {
featureId: feature.id,
content: block.text,
});
} else if (block.type === "thinking") {
// Handle thinking output from Codex O-series models
const thinkingMsg = `\n💭 Thinking: ${block.thinking?.substring(
0,
200
)}...\n`;
await contextManager.writeToContextFile(
projectPath,
feature.id,
thinkingMsg
);
sendToRenderer({
type: "auto_mode_progress",
featureId: feature.id,
content: thinkingMsg,
});
} else if (block.type === "tool_use") {
// First tool use indicates we're actively implementing
if (!hasStartedToolUse) {
@@ -314,6 +654,54 @@ class FeatureExecutor {
console.error("[FeatureExecutor] Error implementing feature:", error);
// Safely get model info for error logging (may not be set if error occurred early)
const modelInfo = modelString
? {
message: error.message,
stack: error.stack,
name: error.name,
code: error.code,
model: modelString,
provider: providerName || "unknown",
isCodex: isCodex !== undefined ? isCodex : "unknown",
}
: {
message: error.message,
stack: error.stack,
name: error.name,
code: error.code,
model: "not initialized",
provider: "unknown",
isCodex: "unknown",
};
console.error("[FeatureExecutor] Error details:", modelInfo);
// Check if this is a Claude CLI process error
if (error.message && error.message.includes("process exited with code")) {
const modelDisplay = modelString
? `Model: ${modelString}`
: "Model: not initialized";
const errorMsg =
`Claude Code CLI failed with exit code 1. This might be due to:\n` +
`- Invalid or unsupported model (${modelDisplay})\n` +
`- Missing or invalid CLAUDE_CODE_OAUTH_TOKEN\n` +
`- Claude CLI configuration issue\n` +
`- Model not available in your Claude account\n\n` +
`Original error: ${error.message}`;
await contextManager.writeToContextFile(
projectPath,
feature.id,
`\n${errorMsg}\n`
);
sendToRenderer({
type: "auto_mode_error",
featureId: feature.id,
error: errorMsg,
});
}
// Clean up
if (execution) {
execution.abortController = null;
@@ -365,9 +753,53 @@ class FeatureExecutor {
projectPath
);
// Ensure feature has a model set (for backward compatibility with old features)
if (!feature.model) {
console.warn(
`[FeatureExecutor] Feature ${feature.id} missing model property, defaulting to 'opus'`
);
feature.model = "opus";
}
// Get model and thinking configuration from feature settings
const modelString = this.getModelString(feature);
const thinkingConfig = this.getThinkingConfig(feature);
// Prepare for ultrathink if needed
if (feature.thinkingLevel === "ultrathink") {
const preparation = this.prepareForUltrathink(feature, thinkingConfig);
console.log(`[FeatureExecutor] Ultrathink preparation:`, preparation);
// Log warnings
if (preparation.warnings && preparation.warnings.length > 0) {
preparation.warnings.forEach((warning) => {
console.warn(`[FeatureExecutor] ⚠️ ${warning}`);
});
}
// Send preparation info to renderer
sendToRenderer({
type: "auto_mode_ultrathink_preparation",
featureId: feature.id,
warnings: preparation.warnings || [],
recommendations: preparation.recommendations || [],
estimatedCost: preparation.estimatedCost,
estimatedTime: preparation.estimatedTime,
});
}
const isCodex = this.isCodexModel(feature);
const providerName = isCodex ? "Codex/OpenAI" : "Claude";
console.log(
`[FeatureExecutor] Resuming with provider: ${providerName}, model: ${modelString}, thinking: ${
feature.thinkingLevel || "none"
}`
);
const options = {
model: "claude-opus-4-5-20251101",
systemPrompt: await promptBuilder.getVerificationPrompt(projectPath, isTDD),
model: modelString,
systemPrompt: promptBuilder.getVerificationPrompt(),
maxTurns: 1000,
cwd: projectPath,
mcpServers: {
@@ -392,6 +824,11 @@ class FeatureExecutor {
abortController: abortController,
};
// Add thinking configuration if enabled
if (thinkingConfig) {
options.thinking = thinkingConfig;
}
// Build prompt with previous context
let prompt = await promptBuilder.buildResumePrompt(
feature,
@@ -459,11 +896,53 @@ class FeatureExecutor {
}
}
// Use content blocks instead of plain text
prompt = contentBlocks;
// Wrap content blocks in async generator for SDK (required format for multimodal prompts)
prompt = (async function* () {
yield {
type: "user",
session_id: "",
message: {
role: "user",
content: contentBlocks,
},
parent_tool_use_id: null,
};
})();
}
const currentQuery = query({ prompt, options });
// Use appropriate provider based on model type
let currentQuery;
if (isCodex) {
// Validate that model string is actually a Codex model
if (modelString.startsWith("claude-")) {
console.error(
`[FeatureExecutor] ERROR: Codex provider selected but Claude model string detected: ${modelString}`
);
throw new Error(
`Invalid model configuration: Codex provider cannot use Claude model '${modelString}'. Please check feature model setting.`
);
}
console.log(
`[FeatureExecutor] Using Codex provider for resume with model: ${modelString}`
);
const provider = this.getProvider(feature);
currentQuery = provider.executeQuery({
prompt,
model: modelString,
cwd: projectPath,
systemPrompt: promptBuilder.getVerificationPrompt(),
maxTurns: 20,
allowedTools: options.allowedTools,
abortController: abortController,
env: {
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
},
});
} else {
// Use Claude SDK
currentQuery = query({ prompt, options });
}
execution.query = currentQuery;
let responseText = "";

View File

@@ -132,9 +132,22 @@ class FeatureLoader {
if (f.summary !== undefined) {
featureData.summary = f.summary;
}
if (f.model !== undefined) {
featureData.model = f.model;
}
if (f.thinkingLevel !== undefined) {
featureData.thinkingLevel = f.thinkingLevel;
}
if (f.error !== undefined) {
featureData.error = f.error;
}
// Preserve worktree info
if (f.worktreePath !== undefined) {
featureData.worktreePath = f.worktreePath;
}
if (f.branchName !== undefined) {
featureData.branchName = f.branchName;
}
return featureData;
});
@@ -157,6 +170,69 @@ class FeatureLoader {
// Skip verified and waiting_approval (which needs user input)
return features.find((f) => f.status !== "verified" && f.status !== "waiting_approval");
}
/**
* Update worktree info for a feature
* @param {string} featureId - The ID of the feature to update
* @param {string} projectPath - Path to the project
* @param {string|null} worktreePath - Path to the worktree (null to clear)
* @param {string|null} branchName - Name of the feature branch (null to clear)
*/
async updateFeatureWorktree(featureId, projectPath, worktreePath, branchName) {
const featuresPath = path.join(
projectPath,
".automaker",
"feature_list.json"
);
const features = await this.loadFeatures(projectPath);
if (!Array.isArray(features) || features.length === 0) {
console.error("[FeatureLoader] Cannot update worktree: feature list is empty");
return;
}
const feature = features.find((f) => f.id === featureId);
if (!feature) {
console.error(`[FeatureLoader] Feature ${featureId} not found`);
return;
}
// Update or clear worktree info
if (worktreePath) {
feature.worktreePath = worktreePath;
feature.branchName = branchName;
} else {
delete feature.worktreePath;
delete feature.branchName;
}
// Save back to file (reuse the same mapping logic)
const toSave = features.map((f) => {
const featureData = {
id: f.id,
category: f.category,
description: f.description,
steps: f.steps,
status: f.status,
};
if (f.skipTests !== undefined) featureData.skipTests = f.skipTests;
if (f.images !== undefined) featureData.images = f.images;
if (f.imagePaths !== undefined) featureData.imagePaths = f.imagePaths;
if (f.startedAt !== undefined) featureData.startedAt = f.startedAt;
if (f.summary !== undefined) featureData.summary = f.summary;
if (f.model !== undefined) featureData.model = f.model;
if (f.thinkingLevel !== undefined) featureData.thinkingLevel = f.thinkingLevel;
if (f.error !== undefined) featureData.error = f.error;
if (f.worktreePath !== undefined) featureData.worktreePath = f.worktreePath;
if (f.branchName !== undefined) featureData.branchName = f.branchName;
return featureData;
});
await fs.writeFile(featuresPath, JSON.stringify(toSave, null, 2), "utf-8");
console.log(`[FeatureLoader] Updated feature ${featureId}: worktreePath=${worktreePath}, branchName=${branchName}`);
}
}
module.exports = new FeatureLoader();

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env node
/**
* Standalone STDIO MCP Server for Automaker Tools
*
* This script runs as a standalone process and communicates via JSON-RPC 2.0
* over stdin/stdout. It implements the MCP protocol to expose the UpdateFeatureStatus
* tool to Codex CLI.
*
* Environment variables:
* - AUTOMAKER_PROJECT_PATH: Path to the project directory
* - AUTOMAKER_IPC_CHANNEL: IPC channel name for callback communication (optional, uses default)
*/
const readline = require('readline');
const path = require('path');
// Redirect all console.log output to stderr to avoid polluting MCP stdout
const originalConsoleLog = console.log;
console.log = (...args) => {
console.error(...args);
};
// Set up readline interface for line-by-line JSON-RPC input
// IMPORTANT: Use a separate output stream for readline to avoid interfering with JSON-RPC stdout
// We'll write JSON-RPC responses directly to stdout, not through readline
const rl = readline.createInterface({
input: process.stdin,
output: null, // Don't use stdout for readline output
terminal: false
});
let initialized = false;
let projectPath = null;
let ipcChannel = null;
// Get configuration from environment
projectPath = process.env.AUTOMAKER_PROJECT_PATH || process.cwd();
ipcChannel = process.env.AUTOMAKER_IPC_CHANNEL || 'mcp:update-feature-status';
// Load dependencies (these will be available in the Electron app context)
let featureLoader;
let electron;
// Try to load Electron IPC if available (when running from Electron app)
try {
// In Electron, we can use IPC directly
if (typeof require !== 'undefined') {
// Check if we're in Electron context
const electronModule = require('electron');
if (electronModule && electronModule.ipcMain) {
electron = electronModule;
}
}
} catch (e) {
// Not in Electron context, will use alternative method
}
// Load feature loader
// Try multiple paths since this script might be run from different contexts
try {
// First try relative path (when run from electron/services/)
featureLoader = require('./feature-loader');
} catch (e) {
try {
// Try absolute path resolution
const featureLoaderPath = path.resolve(__dirname, 'feature-loader.js');
delete require.cache[require.resolve(featureLoaderPath)];
featureLoader = require(featureLoaderPath);
} catch (e2) {
// If still fails, try from parent directory
try {
featureLoader = require(path.join(__dirname, '..', 'services', 'feature-loader'));
} catch (e3) {
console.error('[McpServerStdio] Error loading feature-loader:', e3.message);
console.error('[McpServerStdio] Tried paths:', [
'./feature-loader',
path.resolve(__dirname, 'feature-loader.js'),
path.join(__dirname, '..', 'services', 'feature-loader')
]);
process.exit(1);
}
}
}
/**
* Send JSON-RPC response
* CRITICAL: Must write directly to stdout, not via console.log
* MCP protocol requires ONLY JSON-RPC messages on stdout
*/
function sendResponse(id, result, error = null) {
const response = {
jsonrpc: '2.0',
id
};
if (error) {
response.error = error;
} else {
response.result = result;
}
// Write directly to stdout with newline (MCP uses line-delimited JSON)
process.stdout.write(JSON.stringify(response) + '\n');
}
/**
* Send JSON-RPC notification
* CRITICAL: Must write directly to stdout, not via console.log
*/
function sendNotification(method, params) {
const notification = {
jsonrpc: '2.0',
method,
params
};
// Write directly to stdout with newline (MCP uses line-delimited JSON)
process.stdout.write(JSON.stringify(notification) + '\n');
}
/**
* Handle MCP initialize request
*/
async function handleInitialize(params, id) {
initialized = true;
sendResponse(id, {
protocolVersion: '2024-11-05',
capabilities: {
tools: {}
},
serverInfo: {
name: 'automaker-tools',
version: '1.0.0'
}
});
}
/**
* Handle tools/list request
*/
async function handleToolsList(params, id) {
sendResponse(id, {
tools: [
{
name: 'UpdateFeatureStatus',
description: 'Update the status of a feature in the feature list. Use this tool instead of directly modifying feature_list.json to safely update feature status. IMPORTANT: If the feature has skipTests=true, you should NOT mark it as verified - instead it will automatically go to waiting_approval status for manual review. Always include a summary of what was done.',
inputSchema: {
type: 'object',
properties: {
featureId: {
type: 'string',
description: 'The ID of the feature to update'
},
status: {
type: 'string',
enum: ['backlog', 'in_progress', 'verified'],
description: 'The new status for the feature. Note: If skipTests=true, verified will be converted to waiting_approval automatically.'
},
summary: {
type: 'string',
description: 'A brief summary of what was implemented/changed. This will be displayed on the Kanban card. Example: "Added dark mode toggle. Modified: settings.tsx, theme-provider.tsx"'
}
},
required: ['featureId', 'status']
}
}
]
});
}
/**
* Handle tools/call request
*/
async function handleToolsCall(params, id) {
const { name, arguments: args } = params;
if (name !== 'UpdateFeatureStatus') {
sendResponse(id, null, {
code: -32601,
message: `Unknown tool: ${name}`
});
return;
}
try {
const { featureId, status, summary } = args;
if (!featureId || !status) {
sendResponse(id, null, {
code: -32602,
message: 'Missing required parameters: featureId and status are required'
});
return;
}
// Load the feature to check skipTests flag
const features = await featureLoader.loadFeatures(projectPath);
const feature = features.find((f) => f.id === featureId);
if (!feature) {
sendResponse(id, null, {
code: -32602,
message: `Feature ${featureId} not found`
});
return;
}
// If agent tries to mark as verified but feature has skipTests=true, convert to waiting_approval
let finalStatus = status;
if (status === 'verified' && feature.skipTests === true) {
finalStatus = 'waiting_approval';
}
// Call the update callback via IPC or direct call
// Since we're in a separate process, we need to use IPC to communicate back
// For now, we'll call the feature loader directly since it has the update method
await featureLoader.updateFeatureStatus(featureId, finalStatus, projectPath, summary);
const statusMessage = finalStatus !== status
? `Successfully updated feature ${featureId} to status "${finalStatus}" (converted from "${status}" because skipTests=true)${summary ? ` with summary: "${summary}"` : ''}`
: `Successfully updated feature ${featureId} to status "${finalStatus}"${summary ? ` with summary: "${summary}"` : ''}`;
sendResponse(id, {
content: [
{
type: 'text',
text: statusMessage
}
]
});
} catch (error) {
console.error('[McpServerStdio] UpdateFeatureStatus error:', error);
sendResponse(id, null, {
code: -32603,
message: `Failed to update feature status: ${error.message}`
});
}
}
/**
* Handle JSON-RPC request
*/
async function handleRequest(line) {
let request;
try {
request = JSON.parse(line);
} catch (e) {
sendResponse(null, null, {
code: -32700,
message: 'Parse error'
});
return;
}
// Validate JSON-RPC 2.0 structure
if (request.jsonrpc !== '2.0') {
sendResponse(request.id || null, null, {
code: -32600,
message: 'Invalid Request'
});
return;
}
const { method, params, id } = request;
// Handle notifications (no id)
if (id === undefined) {
// Handle notifications if needed
return;
}
// Handle requests
try {
switch (method) {
case 'initialize':
await handleInitialize(params, id);
break;
case 'tools/list':
if (!initialized) {
sendResponse(id, null, {
code: -32002,
message: 'Server not initialized'
});
return;
}
await handleToolsList(params, id);
break;
case 'tools/call':
if (!initialized) {
sendResponse(id, null, {
code: -32002,
message: 'Server not initialized'
});
return;
}
await handleToolsCall(params, id);
break;
default:
sendResponse(id, null, {
code: -32601,
message: `Method not found: ${method}`
});
}
} catch (error) {
console.error('[McpServerStdio] Error handling request:', error);
sendResponse(id, null, {
code: -32603,
message: `Internal error: ${error.message}`
});
}
}
// Process stdin line by line
rl.on('line', async (line) => {
if (!line.trim()) {
return;
}
await handleRequest(line);
});
// Handle errors
rl.on('error', (error) => {
console.error('[McpServerStdio] Readline error:', error);
process.exit(1);
});
// Handle process termination
process.on('SIGTERM', () => {
rl.close();
process.exit(0);
});
process.on('SIGINT', () => {
rl.close();
process.exit(0);
});
// Log startup
console.error('[McpServerStdio] Starting MCP server for automaker-tools');
console.error(`[McpServerStdio] Project path: ${projectPath}`);
console.error(`[McpServerStdio] IPC channel: ${ipcChannel}`);

View File

@@ -0,0 +1,477 @@
/**
* Model Provider Abstraction Layer
*
* This module provides an abstract interface for model providers (Claude, Codex, etc.)
* allowing the application to use different AI models through a unified API.
*/
/**
* Base class for model providers
* Concrete implementations should extend this class
*/
class ModelProvider {
constructor(config = {}) {
this.config = config;
this.name = 'base';
}
/**
* Get provider name
* @returns {string} Provider name
*/
getName() {
return this.name;
}
/**
* Execute a query with the model provider
* @param {Object} options Query options
* @param {string} options.prompt The prompt to send
* @param {string} options.model The model to use
* @param {string} options.systemPrompt System prompt
* @param {string} options.cwd Working directory
* @param {number} options.maxTurns Maximum turns
* @param {string[]} options.allowedTools Allowed tools
* @param {Object} options.mcpServers MCP servers configuration
* @param {AbortController} options.abortController Abort controller
* @param {Object} options.thinking Thinking configuration
* @returns {AsyncGenerator} Async generator yielding messages
*/
async *executeQuery(options) {
throw new Error('executeQuery must be implemented by subclass');
}
/**
* Detect if this provider's CLI/SDK is installed
* @returns {Promise<Object>} Installation status
*/
async detectInstallation() {
throw new Error('detectInstallation must be implemented by subclass');
}
/**
* Get list of available models for this provider
* @returns {Array<Object>} Array of model definitions
*/
getAvailableModels() {
throw new Error('getAvailableModels must be implemented by subclass');
}
/**
* Validate provider configuration
* @returns {Object} Validation result { valid: boolean, errors: string[] }
*/
validateConfig() {
throw new Error('validateConfig must be implemented by subclass');
}
/**
* Get the full model string for a model key
* @param {string} modelKey Short model key (e.g., 'opus', 'gpt-5.1-codex')
* @returns {string} Full model string
*/
getModelString(modelKey) {
throw new Error('getModelString must be implemented by subclass');
}
/**
* Check if provider supports a specific feature
* @param {string} feature Feature name (e.g., 'thinking', 'tools', 'streaming')
* @returns {boolean} Whether the feature is supported
*/
supportsFeature(feature) {
return false;
}
}
/**
* Claude Provider - Uses Anthropic Claude Agent SDK
*/
class ClaudeProvider extends ModelProvider {
constructor(config = {}) {
super(config);
this.name = 'claude';
this.sdk = null;
}
/**
* Try to load a Claude OAuth token from the local CLI config (~/.claude/config.json).
* Returns the token string or null if not found.
*/
loadTokenFromCliConfig() {
try {
const fs = require('fs');
const path = require('path');
const configPath = path.join(require('os').homedir(), '.claude', 'config.json');
if (!fs.existsSync(configPath)) {
return null;
}
const raw = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw);
// CLI config stores token as oauth_token (newer) or token (older)
return parsed.oauth_token || parsed.token || null;
} catch (err) {
console.warn('[ClaudeProvider] Failed to read CLI config token:', err?.message);
return null;
}
}
ensureAuthEnv() {
// If API key or token already present, keep as-is.
if (process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_CODE_OAUTH_TOKEN) {
console.log('[ClaudeProvider] Auth already present in environment');
return true;
}
// Try to hydrate from CLI login config
const token = this.loadTokenFromCliConfig();
if (token) {
process.env.CLAUDE_CODE_OAUTH_TOKEN = token;
console.log('[ClaudeProvider] Loaded CLAUDE_CODE_OAUTH_TOKEN from ~/.claude/config.json');
return true;
}
// Check if CLI is installed but not logged in
try {
const claudeCliDetector = require('./claude-cli-detector');
const detection = claudeCliDetector.detectClaudeInstallation();
if (detection.installed && detection.method === 'cli') {
console.error('[ClaudeProvider] Claude CLI is installed but not logged in. Run `claude login` to authenticate.');
} else {
console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)');
}
} catch (err) {
console.error('[ClaudeProvider] No Anthropic auth found (env empty, ~/.claude/config.json missing token)');
}
return false;
}
/**
* Lazily load the Claude SDK
*/
loadSdk() {
if (!this.sdk) {
this.sdk = require('@anthropic-ai/claude-agent-sdk');
}
return this.sdk;
}
async *executeQuery(options) {
// Ensure we have auth; fall back to CLI login token if available.
if (!this.ensureAuthEnv()) {
// Check if CLI is installed to provide better error message
let msg = 'Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.';
try {
const claudeCliDetector = require('./claude-cli-detector');
const detection = claudeCliDetector.detectClaudeInstallation();
if (detection.installed && detection.method === 'cli') {
msg = 'Claude CLI is installed but not authenticated. Run `claude login` to authenticate, or set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN environment variable.';
} else {
msg = 'Missing Anthropic auth. Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN, or install Claude CLI and run `claude login`.';
}
} catch (err) {
// Fallback to default message
}
console.error(`[ClaudeProvider] ${msg}`);
yield { type: 'error', error: msg };
return;
}
const { query } = this.loadSdk();
const sdkOptions = {
model: options.model,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns || 1000,
cwd: options.cwd,
mcpServers: options.mcpServers,
allowedTools: options.allowedTools,
permissionMode: options.permissionMode || 'acceptEdits',
sandbox: options.sandbox,
abortController: options.abortController,
};
// Add thinking configuration if enabled
if (options.thinking) {
sdkOptions.thinking = options.thinking;
}
const currentQuery = query({ prompt: options.prompt, options: sdkOptions });
for await (const msg of currentQuery) {
yield msg;
}
}
async detectInstallation() {
const claudeCliDetector = require('./claude-cli-detector');
return claudeCliDetector.getInstallationInfo();
}
getAvailableModels() {
return [
{
id: 'haiku',
name: 'Claude Haiku',
modelString: 'claude-haiku-4-5',
provider: 'claude',
description: 'Fast and efficient for simple tasks',
tier: 'basic'
},
{
id: 'sonnet',
name: 'Claude Sonnet',
modelString: 'claude-sonnet-4-20250514',
provider: 'claude',
description: 'Balanced performance and capabilities',
tier: 'standard'
},
{
id: 'opus',
name: 'Claude Opus 4.5',
modelString: 'claude-opus-4-5-20251101',
provider: 'claude',
description: 'Most capable model for complex tasks',
tier: 'premium'
}
];
}
validateConfig() {
const errors = [];
// Ensure auth is available (try to auto-load from CLI config)
this.ensureAuthEnv();
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN && !process.env.ANTHROPIC_API_KEY) {
errors.push('No Claude authentication found. Set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY, or run `claude login` to populate ~/.claude/config.json.');
}
return {
valid: errors.length === 0,
errors
};
}
getModelString(modelKey) {
const modelMap = {
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-4-20250514',
opus: 'claude-opus-4-5-20251101'
};
return modelMap[modelKey] || modelMap.opus;
}
supportsFeature(feature) {
const supportedFeatures = ['thinking', 'tools', 'streaming', 'mcp'];
return supportedFeatures.includes(feature);
}
}
/**
* Codex Provider - Uses OpenAI Codex CLI
*/
class CodexProvider extends ModelProvider {
constructor(config = {}) {
super(config);
this.name = 'codex';
}
async *executeQuery(options) {
const codexExecutor = require('./codex-executor');
// Validate that we're not receiving a Claude model string
if (options.model && options.model.startsWith('claude-')) {
const errorMsg = `Codex provider cannot use Claude model '${options.model}'. Codex only supports OpenAI models (gpt-5.1-codex-max, gpt-5.1-codex, gpt-5.1-codex-mini, gpt-5.1).`;
console.error(`[CodexProvider] ${errorMsg}`);
yield {
type: 'error',
error: errorMsg
};
return;
}
const executeOptions = {
prompt: options.prompt,
model: options.model,
cwd: options.cwd,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns || 20,
allowedTools: options.allowedTools,
mcpServers: options.mcpServers, // Pass MCP servers config to executor
env: {
...process.env,
OPENAI_API_KEY: process.env.OPENAI_API_KEY
}
};
// Execute and yield results
const generator = codexExecutor.execute(executeOptions);
for await (const msg of generator) {
yield msg;
}
}
async detectInstallation() {
const codexCliDetector = require('./codex-cli-detector');
return codexCliDetector.getInstallationInfo();
}
getAvailableModels() {
return [
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
modelString: 'gpt-5.1-codex-max',
provider: 'codex',
description: 'Latest flagship - deep and fast reasoning for coding',
tier: 'premium',
default: true
},
{
id: 'gpt-5.1-codex',
name: 'GPT-5.1 Codex',
modelString: 'gpt-5.1-codex',
provider: 'codex',
description: 'Optimized for code generation',
tier: 'standard'
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
modelString: 'gpt-5.1-codex-mini',
provider: 'codex',
description: 'Faster and cheaper option',
tier: 'basic'
},
{
id: 'gpt-5.1',
name: 'GPT-5.1',
modelString: 'gpt-5.1',
provider: 'codex',
description: 'Broad world knowledge with strong reasoning',
tier: 'standard'
}
];
}
validateConfig() {
const errors = [];
const codexCliDetector = require('./codex-cli-detector');
const installation = codexCliDetector.detectCodexInstallation();
if (!installation.installed && !process.env.OPENAI_API_KEY) {
errors.push('Codex CLI not installed and no OPENAI_API_KEY found.');
}
return {
valid: errors.length === 0,
errors
};
}
getModelString(modelKey) {
// Codex models use the key directly as the model string
const modelMap = {
'gpt-5.1-codex-max': 'gpt-5.1-codex-max',
'gpt-5.1-codex': 'gpt-5.1-codex',
'gpt-5.1-codex-mini': 'gpt-5.1-codex-mini',
'gpt-5.1': 'gpt-5.1'
};
return modelMap[modelKey] || 'gpt-5.1-codex-max';
}
supportsFeature(feature) {
const supportedFeatures = ['tools', 'streaming'];
return supportedFeatures.includes(feature);
}
}
/**
* Model Provider Factory
* Creates the appropriate provider based on model or provider name
*/
class ModelProviderFactory {
static providers = {
claude: ClaudeProvider,
codex: CodexProvider
};
/**
* Get provider for a specific model
* @param {string} modelId Model ID (e.g., 'opus', 'gpt-5.1-codex')
* @returns {ModelProvider} Provider instance
*/
static getProviderForModel(modelId) {
// Check if it's a Claude model
const claudeModels = ['haiku', 'sonnet', 'opus'];
if (claudeModels.includes(modelId)) {
return new ClaudeProvider();
}
// Check if it's a Codex/OpenAI model
const codexModels = [
'gpt-5.1-codex-max', 'gpt-5.1-codex', 'gpt-5.1-codex-mini', 'gpt-5.1'
];
if (codexModels.includes(modelId)) {
return new CodexProvider();
}
// Default to Claude
return new ClaudeProvider();
}
/**
* Get provider by name
* @param {string} providerName Provider name ('claude' or 'codex')
* @returns {ModelProvider} Provider instance
*/
static getProvider(providerName) {
const ProviderClass = this.providers[providerName];
if (!ProviderClass) {
throw new Error(`Unknown provider: ${providerName}`);
}
return new ProviderClass();
}
/**
* Get all available providers
* @returns {string[]} List of provider names
*/
static getAvailableProviders() {
return Object.keys(this.providers);
}
/**
* Get all available models across all providers
* @returns {Array<Object>} All available models
*/
static getAllModels() {
const allModels = [];
for (const providerName of this.getAvailableProviders()) {
const provider = this.getProvider(providerName);
const models = provider.getAvailableModels();
allModels.push(...models);
}
return allModels;
}
/**
* Check installation status for all providers
* @returns {Promise<Object>} Installation status for each provider
*/
static async checkAllProviders() {
const status = {};
for (const providerName of this.getAvailableProviders()) {
const provider = this.getProvider(providerName);
status[providerName] = await provider.detectInstallation();
}
return status;
}
}
module.exports = {
ModelProvider,
ClaudeProvider,
CodexProvider,
ModelProviderFactory
};

View File

@@ -0,0 +1,320 @@
/**
* Model Registry - Centralized model definitions and metadata
*
* This module provides a central registry of all available models
* across different providers (Claude, Codex/OpenAI).
*/
/**
* Model Categories
*/
const MODEL_CATEGORIES = {
CLAUDE: 'claude',
OPENAI: 'openai',
CODEX: 'codex'
};
/**
* Model Tiers (capability levels)
*/
const MODEL_TIERS = {
BASIC: 'basic', // Fast, cheap, simple tasks
STANDARD: 'standard', // Balanced performance
PREMIUM: 'premium' // Most capable, complex tasks
};
const CODEX_MODEL_IDS = [
'gpt-5.1-codex-max',
'gpt-5.1-codex',
'gpt-5.1-codex-mini',
'gpt-5.1'
];
/**
* All available models with full metadata
*/
const MODELS = {
// Claude Models
haiku: {
id: 'haiku',
name: 'Claude Haiku',
modelString: 'claude-haiku-4-5',
provider: 'claude',
category: MODEL_CATEGORIES.CLAUDE,
tier: MODEL_TIERS.BASIC,
description: 'Fast and efficient for simple tasks',
capabilities: ['code', 'text', 'tools'],
maxTokens: 8192,
contextWindow: 200000,
supportsThinking: true,
requiresAuth: 'CLAUDE_CODE_OAUTH_TOKEN'
},
sonnet: {
id: 'sonnet',
name: 'Claude Sonnet',
modelString: 'claude-sonnet-4-20250514',
provider: 'claude',
category: MODEL_CATEGORIES.CLAUDE,
tier: MODEL_TIERS.STANDARD,
description: 'Balanced performance and capabilities',
capabilities: ['code', 'text', 'tools', 'analysis'],
maxTokens: 8192,
contextWindow: 200000,
supportsThinking: true,
requiresAuth: 'CLAUDE_CODE_OAUTH_TOKEN'
},
opus: {
id: 'opus',
name: 'Claude Opus 4.5',
modelString: 'claude-opus-4-5-20251101',
provider: 'claude',
category: MODEL_CATEGORIES.CLAUDE,
tier: MODEL_TIERS.PREMIUM,
description: 'Most capable model for complex tasks',
capabilities: ['code', 'text', 'tools', 'analysis', 'reasoning'],
maxTokens: 8192,
contextWindow: 200000,
supportsThinking: true,
requiresAuth: 'CLAUDE_CODE_OAUTH_TOKEN',
default: true
},
// OpenAI GPT-5.1 Codex Models
'gpt-5.1-codex-max': {
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
modelString: 'gpt-5.1-codex-max',
provider: 'codex',
category: MODEL_CATEGORIES.OPENAI,
tier: MODEL_TIERS.PREMIUM,
description: 'Latest flagship - deep and fast reasoning for coding',
capabilities: ['code', 'text', 'tools', 'reasoning'],
maxTokens: 32768,
contextWindow: 128000,
supportsThinking: false,
requiresAuth: 'OPENAI_API_KEY',
codexDefault: true
},
'gpt-5.1-codex': {
id: 'gpt-5.1-codex',
name: 'GPT-5.1 Codex',
modelString: 'gpt-5.1-codex',
provider: 'codex',
category: MODEL_CATEGORIES.OPENAI,
tier: MODEL_TIERS.STANDARD,
description: 'Optimized for code generation',
capabilities: ['code', 'text', 'tools'],
maxTokens: 32768,
contextWindow: 128000,
supportsThinking: false,
requiresAuth: 'OPENAI_API_KEY'
},
'gpt-5.1-codex-mini': {
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
modelString: 'gpt-5.1-codex-mini',
provider: 'codex',
category: MODEL_CATEGORIES.OPENAI,
tier: MODEL_TIERS.BASIC,
description: 'Faster and cheaper option',
capabilities: ['code', 'text'],
maxTokens: 16384,
contextWindow: 128000,
supportsThinking: false,
requiresAuth: 'OPENAI_API_KEY'
},
'gpt-5.1': {
id: 'gpt-5.1',
name: 'GPT-5.1',
modelString: 'gpt-5.1',
provider: 'codex',
category: MODEL_CATEGORIES.OPENAI,
tier: MODEL_TIERS.STANDARD,
description: 'Broad world knowledge with strong reasoning',
capabilities: ['code', 'text', 'reasoning'],
maxTokens: 32768,
contextWindow: 128000,
supportsThinking: false,
requiresAuth: 'OPENAI_API_KEY'
}
};
/**
* Model Registry class for querying and managing models
*/
class ModelRegistry {
/**
* Get all registered models
* @returns {Object} All models
*/
static getAllModels() {
return MODELS;
}
/**
* Get model by ID
* @param {string} modelId Model ID
* @returns {Object|null} Model definition or null
*/
static getModel(modelId) {
return MODELS[modelId] || null;
}
/**
* Get models by provider
* @param {string} provider Provider name ('claude' or 'codex')
* @returns {Object[]} Array of models for the provider
*/
static getModelsByProvider(provider) {
return Object.values(MODELS).filter(m => m.provider === provider);
}
/**
* Get models by category
* @param {string} category Category name
* @returns {Object[]} Array of models in the category
*/
static getModelsByCategory(category) {
return Object.values(MODELS).filter(m => m.category === category);
}
/**
* Get models by tier
* @param {string} tier Tier name
* @returns {Object[]} Array of models in the tier
*/
static getModelsByTier(tier) {
return Object.values(MODELS).filter(m => m.tier === tier);
}
/**
* Get default model for a provider
* @param {string} provider Provider name
* @returns {Object|null} Default model or null
*/
static getDefaultModel(provider = 'claude') {
const models = this.getModelsByProvider(provider);
if (provider === 'claude') {
return models.find(m => m.default) || models[0];
}
if (provider === 'codex') {
return models.find(m => m.codexDefault) || models[0];
}
return models[0];
}
/**
* Get model string (full model name) for a model ID
* @param {string} modelId Model ID
* @returns {string} Full model string
*/
static getModelString(modelId) {
const model = this.getModel(modelId);
return model ? model.modelString : modelId;
}
/**
* Determine provider for a model ID
* @param {string} modelId Model ID
* @returns {string} Provider name ('claude' or 'codex')
*/
static getProviderForModel(modelId) {
const model = this.getModel(modelId);
if (model) {
return model.provider;
}
// Fallback detection for models not explicitly registered (keeps legacy Codex IDs working)
if (CODEX_MODEL_IDS.includes(modelId)) {
return 'codex';
}
return 'claude';
}
/**
* Check if a model is a Claude model
* @param {string} modelId Model ID
* @returns {boolean} Whether it's a Claude model
*/
static isClaudeModel(modelId) {
return this.getProviderForModel(modelId) === 'claude';
}
/**
* Check if a model is a Codex/OpenAI model
* @param {string} modelId Model ID
* @returns {boolean} Whether it's a Codex model
*/
static isCodexModel(modelId) {
return this.getProviderForModel(modelId) === 'codex';
}
/**
* Get models grouped by provider for UI display
* @returns {Object} Models grouped by provider
*/
static getModelsGroupedByProvider() {
return {
claude: this.getModelsByProvider('claude'),
codex: this.getModelsByProvider('codex')
};
}
/**
* Get all model IDs as an array
* @returns {string[]} Array of model IDs
*/
static getAllModelIds() {
return Object.keys(MODELS);
}
/**
* Check if model supports a specific capability
* @param {string} modelId Model ID
* @param {string} capability Capability name
* @returns {boolean} Whether the model supports the capability
*/
static modelSupportsCapability(modelId, capability) {
const model = this.getModel(modelId);
return model ? model.capabilities.includes(capability) : false;
}
/**
* Check if model supports extended thinking
* @param {string} modelId Model ID
* @returns {boolean} Whether the model supports thinking
*/
static modelSupportsThinking(modelId) {
const model = this.getModel(modelId);
return model ? model.supportsThinking : false;
}
/**
* Get required authentication for a model
* @param {string} modelId Model ID
* @returns {string|null} Required auth env variable name
*/
static getRequiredAuth(modelId) {
const model = this.getModel(modelId);
return model ? model.requiresAuth : null;
}
/**
* Check if authentication is available for a model
* @param {string} modelId Model ID
* @returns {boolean} Whether auth is available
*/
static hasAuthForModel(modelId) {
const authVar = this.getRequiredAuth(modelId);
if (!authVar) return false;
return !!process.env[authVar];
}
}
module.exports = {
MODEL_CATEGORIES,
MODEL_TIERS,
MODELS,
ModelRegistry
};

View File

@@ -0,0 +1,576 @@
const path = require("path");
const fs = require("fs/promises");
const { exec, spawn } = require("child_process");
const { promisify } = require("util");
const execAsync = promisify(exec);
/**
* Worktree Manager - Handles git worktrees for feature isolation
*
* This service creates isolated git worktrees for each feature, allowing:
* - Features to be worked on in isolation without affecting the main branch
* - Easy rollback/revert by simply deleting the worktree
* - Checkpointing - user can see changes in the worktree before merging
*/
class WorktreeManager {
constructor() {
// Cache for worktree info
this.worktreeCache = new Map();
}
/**
* Get the base worktree directory path
*/
getWorktreeBasePath(projectPath) {
return path.join(projectPath, ".automaker", "worktrees");
}
/**
* Generate a safe branch name from feature description
*/
generateBranchName(feature) {
// Create a slug from the description
const slug = feature.description
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "") // Remove special chars
.replace(/\s+/g, "-") // Replace spaces with hyphens
.substring(0, 40); // Limit length
// Add feature ID for uniqueness
const shortId = feature.id.replace("feature-", "").substring(0, 12);
return `feature/${shortId}-${slug}`;
}
/**
* Check if the project is a git repository
*/
async isGitRepo(projectPath) {
try {
await execAsync("git rev-parse --is-inside-work-tree", { cwd: projectPath });
return true;
} catch {
return false;
}
}
/**
* Get the current branch name
*/
async getCurrentBranch(projectPath) {
try {
const { stdout } = await execAsync("git rev-parse --abbrev-ref HEAD", { cwd: projectPath });
return stdout.trim();
} catch (error) {
console.error("[WorktreeManager] Failed to get current branch:", error);
return null;
}
}
/**
* Check if a branch exists (local or remote)
*/
async branchExists(projectPath, branchName) {
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
return true;
} catch {
return false;
}
}
/**
* List all existing worktrees
*/
async listWorktrees(projectPath) {
try {
const { stdout } = await execAsync("git worktree list --porcelain", { cwd: projectPath });
const worktrees = [];
const lines = stdout.split("\n");
let currentWorktree = null;
for (const line of lines) {
if (line.startsWith("worktree ")) {
if (currentWorktree) {
worktrees.push(currentWorktree);
}
currentWorktree = { path: line.replace("worktree ", "") };
} else if (line.startsWith("branch ") && currentWorktree) {
currentWorktree.branch = line.replace("branch refs/heads/", "");
} else if (line.startsWith("HEAD ") && currentWorktree) {
currentWorktree.head = line.replace("HEAD ", "");
}
}
if (currentWorktree) {
worktrees.push(currentWorktree);
}
return worktrees;
} catch (error) {
console.error("[WorktreeManager] Failed to list worktrees:", error);
return [];
}
}
/**
* Create a worktree for a feature
* @param {string} projectPath - Path to the main project
* @param {object} feature - Feature object with id and description
* @returns {object} - { success, worktreePath, branchName, error }
*/
async createWorktree(projectPath, feature) {
console.log(`[WorktreeManager] Creating worktree for feature: ${feature.id}`);
// Check if project is a git repo
if (!await this.isGitRepo(projectPath)) {
return { success: false, error: "Project is not a git repository" };
}
const branchName = this.generateBranchName(feature);
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
const worktreePath = path.join(worktreeBasePath, branchName.replace("feature/", ""));
try {
// Ensure worktree directory exists
await fs.mkdir(worktreeBasePath, { recursive: true });
// Check if worktree already exists
const worktrees = await this.listWorktrees(projectPath);
const existingWorktree = worktrees.find(
w => w.path === worktreePath || w.branch === branchName
);
if (existingWorktree) {
console.log(`[WorktreeManager] Worktree already exists for feature: ${feature.id}`);
return {
success: true,
worktreePath: existingWorktree.path,
branchName: existingWorktree.branch,
existed: true,
};
}
// Get current branch to base the new branch on
const baseBranch = await this.getCurrentBranch(projectPath);
if (!baseBranch) {
return { success: false, error: "Could not determine current branch" };
}
// Check if branch already exists
const branchExists = await this.branchExists(projectPath, branchName);
if (branchExists) {
// Use existing branch
console.log(`[WorktreeManager] Using existing branch: ${branchName}`);
await execAsync(`git worktree add "${worktreePath}" ${branchName}`, { cwd: projectPath });
} else {
// Create new worktree with new branch
console.log(`[WorktreeManager] Creating new branch: ${branchName} based on ${baseBranch}`);
await execAsync(`git worktree add -b ${branchName} "${worktreePath}" ${baseBranch}`, { cwd: projectPath });
}
// Copy .automaker directory to worktree (except worktrees directory itself to avoid recursion)
const automakerSrc = path.join(projectPath, ".automaker");
const automakerDst = path.join(worktreePath, ".automaker");
try {
await fs.mkdir(automakerDst, { recursive: true });
// Copy feature_list.json
const featureListSrc = path.join(automakerSrc, "feature_list.json");
const featureListDst = path.join(automakerDst, "feature_list.json");
try {
const content = await fs.readFile(featureListSrc, "utf-8");
await fs.writeFile(featureListDst, content, "utf-8");
} catch {
// Feature list might not exist yet
}
// Copy app_spec.txt if it exists
const appSpecSrc = path.join(automakerSrc, "app_spec.txt");
const appSpecDst = path.join(automakerDst, "app_spec.txt");
try {
const content = await fs.readFile(appSpecSrc, "utf-8");
await fs.writeFile(appSpecDst, content, "utf-8");
} catch {
// App spec might not exist yet
}
// Copy categories.json if it exists
const categoriesSrc = path.join(automakerSrc, "categories.json");
const categoriesDst = path.join(automakerDst, "categories.json");
try {
const content = await fs.readFile(categoriesSrc, "utf-8");
await fs.writeFile(categoriesDst, content, "utf-8");
} catch {
// Categories might not exist yet
}
} catch (error) {
console.warn("[WorktreeManager] Failed to copy .automaker directory:", error);
}
// Store worktree info in cache
this.worktreeCache.set(feature.id, {
worktreePath,
branchName,
createdAt: new Date().toISOString(),
baseBranch,
});
console.log(`[WorktreeManager] Worktree created at: ${worktreePath}`);
return {
success: true,
worktreePath,
branchName,
baseBranch,
existed: false,
};
} catch (error) {
console.error("[WorktreeManager] Failed to create worktree:", error);
return { success: false, error: error.message };
}
}
/**
* Get worktree info for a feature
*/
async getWorktreeInfo(projectPath, featureId) {
// Check cache first
if (this.worktreeCache.has(featureId)) {
return { success: true, ...this.worktreeCache.get(featureId) };
}
// Scan worktrees to find matching one
const worktrees = await this.listWorktrees(projectPath);
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
for (const worktree of worktrees) {
// Check if this worktree is in our worktree directory
if (worktree.path.startsWith(worktreeBasePath)) {
// Check if the feature ID is in the branch name
const shortId = featureId.replace("feature-", "").substring(0, 12);
if (worktree.branch && worktree.branch.includes(shortId)) {
const info = {
worktreePath: worktree.path,
branchName: worktree.branch,
head: worktree.head,
};
this.worktreeCache.set(featureId, info);
return { success: true, ...info };
}
}
}
return { success: false, error: "Worktree not found" };
}
/**
* Remove a worktree for a feature
* This effectively reverts all changes made by the agent
*/
async removeWorktree(projectPath, featureId, deleteBranch = false) {
console.log(`[WorktreeManager] Removing worktree for feature: ${featureId}`);
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
if (!worktreeInfo.success) {
console.log(`[WorktreeManager] No worktree found for feature: ${featureId}`);
return { success: true, message: "No worktree to remove" };
}
const { worktreePath, branchName } = worktreeInfo;
try {
// Remove the worktree
await execAsync(`git worktree remove "${worktreePath}" --force`, { cwd: projectPath });
console.log(`[WorktreeManager] Worktree removed: ${worktreePath}`);
// Optionally delete the branch too
if (deleteBranch && branchName) {
try {
await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
console.log(`[WorktreeManager] Branch deleted: ${branchName}`);
} catch (error) {
console.warn(`[WorktreeManager] Could not delete branch ${branchName}:`, error.message);
}
}
// Remove from cache
this.worktreeCache.delete(featureId);
return { success: true, removedPath: worktreePath, removedBranch: deleteBranch ? branchName : null };
} catch (error) {
console.error("[WorktreeManager] Failed to remove worktree:", error);
return { success: false, error: error.message };
}
}
/**
* Get status of changes in a worktree
*/
async getWorktreeStatus(worktreePath) {
try {
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
const { stdout: diffStat } = await execAsync("git diff --stat", { cwd: worktreePath });
const { stdout: commitLog } = await execAsync("git log --oneline -10", { cwd: worktreePath });
const files = statusOutput.trim().split("\n").filter(Boolean);
const commits = commitLog.trim().split("\n").filter(Boolean);
return {
success: true,
modifiedFiles: files.length,
files: files.slice(0, 20), // Limit to 20 files
diffStat: diffStat.trim(),
recentCommits: commits.slice(0, 5), // Last 5 commits
};
} catch (error) {
console.error("[WorktreeManager] Failed to get worktree status:", error);
return { success: false, error: error.message };
}
}
/**
* Get detailed file diff content for a worktree
* Returns unified diff format for all changes
*/
async getFileDiffs(worktreePath) {
try {
// Get both staged and unstaged diffs
const { stdout: unstagedDiff } = await execAsync("git diff --no-color", {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large diffs
});
const { stdout: stagedDiff } = await execAsync("git diff --cached --no-color", {
cwd: worktreePath,
maxBuffer: 10 * 1024 * 1024
});
// Get list of files with their status
const { stdout: statusOutput } = await execAsync("git status --porcelain", { cwd: worktreePath });
const files = statusOutput.trim().split("\n").filter(Boolean);
// Parse file statuses
const fileStatuses = files.map(line => {
const status = line.substring(0, 2);
const filePath = line.substring(3);
return {
status: status.trim() || 'M',
path: filePath,
statusText: this.getStatusText(status)
};
});
// Combine diffs
const combinedDiff = [stagedDiff, unstagedDiff].filter(Boolean).join("\n");
return {
success: true,
diff: combinedDiff,
files: fileStatuses,
hasChanges: files.length > 0
};
} catch (error) {
console.error("[WorktreeManager] Failed to get file diffs:", error);
return { success: false, error: error.message };
}
}
/**
* Get human-readable status text from git status code
*/
getStatusText(status) {
const statusMap = {
'M': 'Modified',
'A': 'Added',
'D': 'Deleted',
'R': 'Renamed',
'C': 'Copied',
'U': 'Updated',
'?': 'Untracked',
'!': 'Ignored'
};
const firstChar = status.charAt(0);
const secondChar = status.charAt(1);
return statusMap[firstChar] || statusMap[secondChar] || 'Changed';
}
/**
* Get diff for a specific file in a worktree
*/
async getFileDiff(worktreePath, filePath) {
try {
// Try to get unstaged diff first, then staged if no unstaged changes
let diff = '';
try {
const { stdout } = await execAsync(`git diff --no-color -- "${filePath}"`, {
cwd: worktreePath,
maxBuffer: 5 * 1024 * 1024
});
diff = stdout;
} catch {
// File might be staged
}
if (!diff) {
try {
const { stdout } = await execAsync(`git diff --cached --no-color -- "${filePath}"`, {
cwd: worktreePath,
maxBuffer: 5 * 1024 * 1024
});
diff = stdout;
} catch {
// File might be untracked, show the content
}
}
// If still no diff, might be an untracked file - show the content
if (!diff) {
try {
const fullPath = path.join(worktreePath, filePath);
const content = await fs.readFile(fullPath, 'utf-8');
diff = `+++ ${filePath} (new file)\n${content.split('\n').map(l => '+' + l).join('\n')}`;
} catch {
diff = '(Unable to read file content)';
}
}
return {
success: true,
diff,
filePath
};
} catch (error) {
console.error(`[WorktreeManager] Failed to get diff for ${filePath}:`, error);
return { success: false, error: error.message };
}
}
/**
* Merge worktree changes back to the main branch
*/
async mergeWorktree(projectPath, featureId, options = {}) {
console.log(`[WorktreeManager] Merging worktree for feature: ${featureId}`);
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
if (!worktreeInfo.success) {
return { success: false, error: "Worktree not found" };
}
const { branchName, worktreePath } = worktreeInfo;
const baseBranch = await this.getCurrentBranch(projectPath);
try {
// First commit any uncommitted changes in the worktree
const { stdout: status } = await execAsync("git status --porcelain", { cwd: worktreePath });
if (status.trim()) {
// There are uncommitted changes - commit them
await execAsync("git add -A", { cwd: worktreePath });
const commitMsg = options.commitMessage || `feat: complete ${featureId}`;
await execAsync(`git commit -m "${commitMsg}"`, { cwd: worktreePath });
}
// Merge the feature branch into the current branch in the main repo
if (options.squash) {
await execAsync(`git merge --squash ${branchName}`, { cwd: projectPath });
const squashMsg = options.squashMessage || `feat: ${featureId} - squashed merge`;
await execAsync(`git commit -m "${squashMsg}"`, { cwd: projectPath });
} else {
await execAsync(`git merge ${branchName} --no-ff -m "Merge ${branchName}"`, { cwd: projectPath });
}
console.log(`[WorktreeManager] Successfully merged ${branchName} into ${baseBranch}`);
// Optionally cleanup worktree after merge
if (options.cleanup) {
await this.removeWorktree(projectPath, featureId, true);
}
return {
success: true,
mergedBranch: branchName,
intoBranch: baseBranch,
};
} catch (error) {
console.error("[WorktreeManager] Failed to merge worktree:", error);
return { success: false, error: error.message };
}
}
/**
* Sync changes from main branch to worktree (rebase or merge)
*/
async syncWorktree(projectPath, featureId, method = "rebase") {
console.log(`[WorktreeManager] Syncing worktree for feature: ${featureId}`);
const worktreeInfo = await this.getWorktreeInfo(projectPath, featureId);
if (!worktreeInfo.success) {
return { success: false, error: "Worktree not found" };
}
const { worktreePath, baseBranch } = worktreeInfo;
try {
if (method === "rebase") {
await execAsync(`git rebase ${baseBranch}`, { cwd: worktreePath });
} else {
await execAsync(`git merge ${baseBranch}`, { cwd: worktreePath });
}
return { success: true, method };
} catch (error) {
console.error("[WorktreeManager] Failed to sync worktree:", error);
return { success: false, error: error.message };
}
}
/**
* Get list of all feature worktrees
*/
async getAllFeatureWorktrees(projectPath) {
const worktrees = await this.listWorktrees(projectPath);
const worktreeBasePath = this.getWorktreeBasePath(projectPath);
return worktrees.filter(w =>
w.path.startsWith(worktreeBasePath) &&
w.branch &&
w.branch.startsWith("feature/")
);
}
/**
* Cleanup orphaned worktrees (worktrees without matching features)
*/
async cleanupOrphanedWorktrees(projectPath, activeFeatureIds) {
console.log("[WorktreeManager] Cleaning up orphaned worktrees...");
const worktrees = await this.getAllFeatureWorktrees(projectPath);
const cleaned = [];
for (const worktree of worktrees) {
// Extract feature ID from branch name
const branchParts = worktree.branch.replace("feature/", "").split("-");
const shortId = branchParts[0];
// Check if any active feature has this short ID
const hasMatchingFeature = activeFeatureIds.some(id => {
const featureShortId = id.replace("feature-", "").substring(0, 12);
return featureShortId === shortId;
});
if (!hasMatchingFeature) {
console.log(`[WorktreeManager] Removing orphaned worktree: ${worktree.path}`);
try {
await execAsync(`git worktree remove "${worktree.path}" --force`, { cwd: projectPath });
await execAsync(`git branch -D ${worktree.branch}`, { cwd: projectPath });
cleaned.push(worktree.path);
} catch (error) {
console.warn(`[WorktreeManager] Failed to cleanup worktree ${worktree.path}:`, error.message);
}
}
}
return { success: true, cleaned };
}
}
module.exports = new WorktreeManager();

View File

@@ -1,417 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
Sparkles,
Wand2,
LayoutGrid,
Layers,
FolderOpen,
FileText,
List,
Cpu,
Search,
Share2,
Trash2,
BarChart3,
Settings,
PanelLeftClose,
PanelLeft,
Home,
LogOut,
User,
CreditCard,
} from "lucide-react";
interface AppSidebarProps {
user: any;
creditsBalance: number | null;
}
interface NavItem {
href: string;
icon: any;
label: string;
}
interface NavSection {
label?: string;
items: NavItem[];
}
export function AppSidebar({ user, creditsBalance }: AppSidebarProps) {
const pathname = usePathname();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
userMenuRef.current &&
!userMenuRef.current.contains(event.target as Node)
) {
setUserMenuOpen(false);
}
}
if (userMenuOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}
}, [userMenuOpen]);
const navSections: NavSection[] = [
{
items: [
{ href: "/generate", icon: Home, label: "Overview" },
{ href: "/generate/canvas", icon: Wand2, label: "Canvas" },
],
},
{
label: "Content",
items: [
{ href: "/generate/gallery", icon: LayoutGrid, label: "Gallery" },
{ href: "/generate/collections", icon: Layers, label: "Collections" },
{ href: "/generate/projects", icon: FolderOpen, label: "Projects" },
{ href: "/generate/prompts", icon: FileText, label: "Prompts" },
],
},
{
label: "Tools",
items: [
{ href: "/generate/batch", icon: List, label: "Batch" },
{ href: "/generate/models", icon: Cpu, label: "Models" },
],
},
{
label: "Manage",
items: [
{ href: "/generate/shared", icon: Share2, label: "Shared" },
{ href: "/generate/trash", icon: Trash2, label: "Trash" },
],
},
];
const isActiveRoute = (href: string) => {
if (href === "/generate") {
return pathname === "/generate";
}
return pathname?.startsWith(href);
};
return (
<aside
className={`${
sidebarCollapsed ? "w-16" : "w-16 lg:w-60"
} flex-shrink-0 border-r border-white/10 bg-zinc-950/50 backdrop-blur-md flex flex-col z-30 transition-all duration-300 relative`}
data-testid="left-sidebar"
data-collapsed={sidebarCollapsed}
>
{/* Floating Collapse Toggle Button */}
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="hidden lg:flex absolute top-20 -right-3 z-50 items-center justify-center w-6 h-6 rounded-full bg-zinc-800 border border-white/10 text-zinc-400 hover:text-white hover:bg-zinc-700 hover:border-white/20 transition-all shadow-lg"
data-testid="sidebar-collapse-button"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{sidebarCollapsed ? (
<PanelLeft className="w-3.5 h-3.5" />
) : (
<PanelLeftClose className="w-3.5 h-3.5" />
)}
</button>
<div className="flex-1 flex flex-col overflow-hidden">
{/* Logo */}
<div className={`h-16 flex items-center border-b border-zinc-800 flex-shrink-0 ${
sidebarCollapsed ? "justify-center" : "justify-center lg:justify-start lg:px-6"
}`}>
<Link href="/generate" className="flex items-center">
<div className="relative flex items-center justify-center w-8 h-8 bg-gradient-to-br from-brand-500 to-purple-600 rounded-lg shadow-lg shadow-brand-500/20 group cursor-pointer">
<Sparkles className="text-white w-5 h-5 group-hover:rotate-12 transition-transform" />
</div>
<span
className={`ml-3 font-bold text-white text-base tracking-tight ${
sidebarCollapsed ? "hidden" : "hidden lg:block"
}`}
>
Image<span className="text-brand-500">Studio</span>
</span>
</Link>
</div>
{/* Nav Items - Scrollable */}
<nav className="flex-1 overflow-y-auto px-2 mt-2 pb-2">
{navSections.map((section, sectionIdx) => (
<div key={sectionIdx} className={sectionIdx > 0 ? "mt-6" : ""}>
{/* Section Label */}
{section.label && !sidebarCollapsed && (
<div className="hidden lg:block px-4 mb-2">
<span className="text-[10px] font-semibold text-zinc-500 uppercase tracking-wider">
{section.label}
</span>
</div>
)}
{section.label && sidebarCollapsed && (
<div className="h-px bg-zinc-800 mx-2 mb-2"></div>
)}
{/* Nav Items */}
<div className="space-y-1">
{section.items.map((item) => {
const isActive = isActiveRoute(item.href);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className={`group flex items-center px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all ${
isActive
? "bg-white/5 text-white border border-white/10"
: "text-zinc-400 hover:text-white hover:bg-white/5"
}`}
title={sidebarCollapsed ? item.label : undefined}
>
{isActive && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Icon
className={`w-4 h-4 flex-shrink-0 transition-colors ${
isActive
? "text-brand-500"
: "group-hover:text-brand-400"
}`}
/>
<span
className={`ml-2.5 font-medium text-sm ${
sidebarCollapsed ? "hidden" : "hidden lg:block"
}`}
>
{item.label}
</span>
{/* Tooltip for collapsed state */}
{sidebarCollapsed && (
<span
className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700"
data-testid={`sidebar-tooltip-${item.label.toLowerCase()}`}
>
{item.label}
</span>
)}
</Link>
);
})}
</div>
</div>
))}
</nav>
</div>
{/* Bottom Section - User / Settings */}
<div className="border-t border-zinc-800 bg-zinc-900/50 flex-shrink-0">
{/* Usage & Settings Links */}
<div className="p-2 space-y-1">
<Link
href="/generate/usage"
className={`group flex items-center px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all ${
isActiveRoute("/generate/usage")
? "bg-white/5 text-white border border-white/10"
: "text-zinc-400 hover:text-white hover:bg-white/5"
}`}
title={sidebarCollapsed ? "Usage" : undefined}
>
{isActiveRoute("/generate/usage") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<BarChart3
className={`w-4 h-4 flex-shrink-0 transition-colors ${
isActiveRoute("/generate/usage")
? "text-brand-500"
: "group-hover:text-brand-400"
}`}
/>
<span
className={`ml-2.5 font-medium text-sm ${
sidebarCollapsed ? "hidden" : "hidden lg:block"
}`}
>
Usage
</span>
{sidebarCollapsed && (
<span className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700">
Usage
</span>
)}
</Link>
<Link
href="/generate/settings"
className={`group flex items-center px-2 lg:px-3 py-2.5 rounded-lg relative overflow-hidden transition-all ${
isActiveRoute("/generate/settings")
? "bg-white/5 text-white border border-white/10"
: "text-zinc-400 hover:text-white hover:bg-white/5"
}`}
title={sidebarCollapsed ? "Settings" : undefined}
>
{isActiveRoute("/generate/settings") && (
<div className="absolute inset-y-0 left-0 w-0.5 bg-brand-500 rounded-l-md"></div>
)}
<Settings
className={`w-4 h-4 flex-shrink-0 transition-colors ${
isActiveRoute("/generate/settings")
? "text-brand-500"
: "group-hover:text-brand-400"
}`}
/>
<span
className={`ml-2.5 font-medium text-sm ${
sidebarCollapsed ? "hidden" : "hidden lg:block"
}`}
>
Settings
</span>
{sidebarCollapsed && (
<span className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700">
Settings
</span>
)}
</Link>
</div>
{/* Credits Display */}
{!sidebarCollapsed && (
<Link href="/generate/usage" className="hidden lg:block mx-3 mb-3">
<div className="p-2.5 bg-white/5 backdrop-blur-sm rounded-lg border border-white/10 hover:bg-white/10 hover:border-white/20 transition-all cursor-pointer">
<div className="flex justify-between text-[11px] font-medium text-zinc-400 mb-1.5">
<span>Credits</span>
<span className="text-white" data-testid="credits-sidebar-balance">
{creditsBalance !== null ? creditsBalance : "..."} / 1000
</span>
</div>
<div className="w-full bg-zinc-800 rounded-full h-1 overflow-hidden">
<div
className="bg-gradient-to-r from-brand-500 to-purple-500 h-1 rounded-full"
style={{
width: `${
creditsBalance !== null
? Math.min((creditsBalance / 1000) * 100, 100)
: 30
}%`,
}}
></div>
</div>
</div>
</Link>
)}
{/* User Profile */}
<div className="p-3 border-t border-zinc-800" ref={userMenuRef}>
<div className="relative">
<button
onClick={() => setUserMenuOpen(!userMenuOpen)}
className={`flex items-center p-1.5 rounded-lg transition-colors group relative w-full hover:bg-white/5 ${
sidebarCollapsed ? "justify-center" : "lg:space-x-2.5"
}`}
>
<div className="relative">
<img
src={
user?.avatarUrl ||
"https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=100&q=80"
}
alt="User"
className="w-8 h-8 rounded-full border border-zinc-600"
/>
<div className="absolute bottom-0 right-0 w-2 h-2 bg-green-500 border-2 border-zinc-900 rounded-full"></div>
</div>
<div
className={`overflow-hidden ${
sidebarCollapsed ? "hidden" : "hidden lg:block"
}`}
>
<p className="text-xs font-medium text-white truncate">
{user ? user.name : "Guest"}
</p>
<p className="text-[10px] text-zinc-500 truncate">
{user ? "Pro Account" : "Guest"}
</p>
</div>
{/* Tooltip for user when collapsed */}
{sidebarCollapsed && (
<span
className="absolute left-full ml-2 px-2 py-1 bg-zinc-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-50 border border-zinc-700"
data-testid="sidebar-tooltip-user"
>
{user ? user.name : "Guest"}
</span>
)}
</button>
{/* Dropdown Menu */}
{userMenuOpen && (
<div
className={`absolute bottom-full mb-2 bg-zinc-800 border border-zinc-700 rounded-xl shadow-lg overflow-hidden z-50 ${
sidebarCollapsed ? "left-0" : "left-0 right-0"
}`}
>
<div className="py-2">
<Link
href="/generate/settings"
onClick={() => setUserMenuOpen(false)}
className="flex items-center px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors"
>
<Settings className="w-4 h-4 mr-3" />
<span>Settings</span>
</Link>
<Link
href="/generate/usage"
onClick={() => setUserMenuOpen(false)}
className="flex items-center px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors"
>
<BarChart3 className="w-4 h-4 mr-3" />
<span>Usage</span>
</Link>
<Link
href="/dashboard/profile"
onClick={() => setUserMenuOpen(false)}
className="flex items-center px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors"
>
<User className="w-4 h-4 mr-3" />
<span>Profile</span>
</Link>
<Link
href="/dashboard/billing"
onClick={() => setUserMenuOpen(false)}
className="flex items-center px-4 py-2 text-sm text-zinc-300 hover:bg-zinc-700/50 hover:text-white transition-colors"
>
<CreditCard className="w-4 h-4 mr-3" />
<span>Billing</span>
</Link>
<div className="border-t border-zinc-700 my-2"></div>
<button
onClick={() => {
setUserMenuOpen(false);
// Add logout logic here
window.location.href = "/api/auth/logout";
}}
className="flex items-center px-4 py-2 text-sm text-red-400 hover:bg-zinc-700/50 hover:text-red-300 transition-colors w-full text-left"
>
<LogOut className="w-4 h-4 mr-3" />
<span>Logout</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
</aside>
);
}

File diff suppressed because it is too large Load Diff

76
app/package-lock.json generated
View File

@@ -20,6 +20,7 @@
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -3186,6 +3187,58 @@
}
}
},
"node_modules/@radix-ui/react-tooltip": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-popper": "1.2.8",
"@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-visually-hidden": "1.2.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -3322,6 +3375,29 @@
}
}
},
"node_modules/@radix-ui/react-visually-hidden": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz",
"integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-primitive": "2.1.3"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/rect": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",

View File

@@ -27,6 +27,7 @@
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.12",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -72,7 +73,9 @@
{
"from": ".env",
"to": ".env",
"filter": ["**/*"]
"filter": [
"**/*"
]
}
],
"mac": {
@@ -80,11 +83,17 @@
"target": [
{
"target": "dmg",
"arch": ["x64", "arm64"]
"arch": [
"x64",
"arm64"
]
},
{
"target": "zip",
"arch": ["x64", "arm64"]
"arch": [
"x64",
"arm64"
]
}
],
"icon": "public/logo.png"
@@ -93,7 +102,9 @@
"target": [
{
"target": "nsis",
"arch": ["x64"]
"arch": [
"x64"
]
}
],
"icon": "public/logo.png"
@@ -102,11 +113,15 @@
"target": [
{
"target": "AppImage",
"arch": ["x64"]
"arch": [
"x64"
]
},
{
"target": "deb",
"arch": ["x64"]
"arch": [
"x64"
]
}
],
"category": "Development",

View File

@@ -1,6 +1,11 @@
import Anthropic from "@anthropic-ai/sdk";
import { NextRequest, NextResponse } from "next/server";
interface AnthropicResponse {
content?: Array<{ type: string; text?: string }>;
model?: string;
error?: { message?: string };
}
export async function POST(request: NextRequest) {
try {
const { apiKey } = await request.json();
@@ -15,31 +20,60 @@ export async function POST(request: NextRequest) {
);
}
// Create Anthropic client with the provided key
const anthropic = new Anthropic({
apiKey: effectiveApiKey,
// Send a simple test prompt to the Anthropic API
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": effectiveApiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 100,
messages: [
{
role: "user",
content: "Respond with exactly: 'Claude API connection successful!' and nothing else.",
},
],
}),
});
// Send a simple test prompt
const response = await anthropic.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 100,
messages: [
{
role: "user",
content: "Respond with exactly: 'Claude SDK connection successful!' and nothing else.",
},
],
});
if (!response.ok) {
const errorData = (await response.json()) as AnthropicResponse;
const errorMessage = errorData.error?.message || `HTTP ${response.status}`;
if (response.status === 401) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
{ status: 401 }
);
}
if (response.status === 429) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
return NextResponse.json(
{ success: false, error: `API error: ${errorMessage}` },
{ status: response.status }
);
}
const data = (await response.json()) as AnthropicResponse;
// Check if we got a valid response
if (response.content && response.content.length > 0) {
const textContent = response.content.find((block) => block.type === "text");
if (textContent && textContent.type === "text") {
if (data.content && data.content.length > 0) {
const textContent = data.content.find((block) => block.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
return NextResponse.json({
success: true,
message: `Connection successful! Response: "${textContent.text}"`,
model: response.model,
model: data.model,
});
}
}
@@ -47,33 +81,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
message: "Connection successful! Claude responded.",
model: response.model,
model: data.model,
});
} catch (error: unknown) {
console.error("Claude API test error:", error);
// Handle specific Anthropic API errors
if (error instanceof Anthropic.AuthenticationError) {
return NextResponse.json(
{ success: false, error: "Invalid API key. Please check your Anthropic API key." },
{ status: 401 }
);
}
if (error instanceof Anthropic.RateLimitError) {
return NextResponse.json(
{ success: false, error: "Rate limit exceeded. Please try again later." },
{ status: 429 }
);
}
if (error instanceof Anthropic.APIError) {
return NextResponse.json(
{ success: false, error: `API error: ${error.message}` },
{ status: error.status || 500 }
);
}
const errorMessage =
error instanceof Error ? error.message : "Failed to connect to Claude API";

View File

@@ -1609,6 +1609,39 @@
box-shadow: 0 0 8px #f97e72;
}
/* Line clamp utilities for text overflow prevention */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
/* Kanban card improvements to prevent text overflow */
.kanban-card-content {
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
/* Ensure proper column layout in double-width kanban columns */
.kanban-columns-layout > * {
page-break-inside: avoid;
break-inside: avoid;
display: block;
width: 100%;
box-sizing: border-box;
}
/* Electron title bar drag region */
.titlebar-drag-region {
-webkit-app-region: drag;

View File

@@ -10,6 +10,7 @@ import { SettingsView } from "@/components/views/settings-view";
import { AgentToolsView } from "@/components/views/agent-tools-view";
import { InterviewView } from "@/components/views/interview-view";
import { ContextView } from "@/components/views/context-view";
import { ProfilesView } from "@/components/views/profiles-view";
import { useAppStore } from "@/store/app-store";
import { getElectronAPI, isElectron } from "@/lib/electron";
@@ -109,6 +110,8 @@ export default function Home() {
return <InterviewView />;
case "context":
return <ContextView />;
case "profiles":
return <ProfilesView />;
default:
return <WelcomeView />;
}

View File

@@ -23,6 +23,7 @@ import {
RotateCcw,
Trash2,
Undo2,
UserCircle,
MoreVertical,
} from "lucide-react";
import {
@@ -487,6 +488,12 @@ export function Sidebar() {
icon: Wrench,
shortcut: NAV_SHORTCUTS.tools,
},
{
id: "profiles",
label: "AI Profiles",
icon: UserCircle,
shortcut: NAV_SHORTCUTS.profiles,
},
],
},
];

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -1,6 +1,6 @@
"use client";
import React, { useState, useRef, useCallback } from "react";
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import { ImageIcon, X, Loader2 } from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
@@ -14,6 +14,9 @@ export interface FeatureImagePath {
mimeType: string;
}
// Map to store preview data by image ID (persisted across component re-mounts)
export type ImagePreviewMap = Map<string, string>;
interface DescriptionImageDropZoneProps {
value: string;
onChange: (value: string) => void;
@@ -24,6 +27,9 @@ interface DescriptionImageDropZoneProps {
disabled?: boolean;
maxFiles?: number;
maxFileSize?: number; // in bytes, default 10MB
// Optional: pass preview map from parent to persist across tab switches
previewMap?: ImagePreviewMap;
onPreviewMapChange?: (map: ImagePreviewMap) => void;
}
const ACCEPTED_IMAGE_TYPES = [
@@ -45,12 +51,31 @@ export function DescriptionImageDropZone({
disabled = false,
maxFiles = 5,
maxFileSize = DEFAULT_MAX_FILE_SIZE,
previewMap,
onPreviewMapChange,
}: DescriptionImageDropZoneProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [previewImages, setPreviewImages] = useState<Map<string, string>>(
new Map()
// Use parent-provided preview map if available, otherwise use local state
const [localPreviewImages, setLocalPreviewImages] = useState<Map<string, string>>(
() => new Map()
);
// Determine which preview map to use - prefer parent-controlled state
const previewImages = previewMap !== undefined ? previewMap : localPreviewImages;
const setPreviewImages = useCallback((updater: Map<string, string> | ((prev: Map<string, string>) => Map<string, string>)) => {
if (onPreviewMapChange) {
const currentMap = previewMap !== undefined ? previewMap : localPreviewImages;
const newMap = typeof updater === 'function' ? updater(currentMap) : updater;
onPreviewMapChange(newMap);
} else {
setLocalPreviewImages((prev) => {
const newMap = typeof updater === 'function' ? updater(prev) : updater;
return newMap;
});
}
}, [onPreviewMapChange, previewMap, localPreviewImages]);
const fileInputRef = useRef<HTMLInputElement>(null);
const currentProject = useAppStore((state) => state.currentProject);

View File

@@ -50,9 +50,11 @@ function DialogContent({
className,
children,
showCloseButton = true,
compact = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
compact?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
@@ -60,7 +62,8 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 flex flex-col w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] rounded-lg border shadow-lg duration-200 max-h-[calc(100vh-4rem)]",
compact ? "max-w-2xl p-4" : "sm:max-w-2xl p-6",
className
)}
{...props}
@@ -69,7 +72,10 @@ function DialogContent({
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
className={cn(
"ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
compact ? "top-2 right-2" : "top-4 right-4"
)}
>
<XIcon />
<span className="sr-only">Close</span>

View File

@@ -0,0 +1,628 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { getElectronAPI } from "@/lib/electron";
import { cn } from "@/lib/utils";
import {
File,
FileText,
FilePlus,
FileX,
FilePen,
ChevronDown,
ChevronRight,
Loader2,
RefreshCw,
GitBranch,
AlertCircle,
} from "lucide-react";
import { Button } from "./button";
import type { FileStatus } from "@/types/electron";
interface GitDiffPanelProps {
projectPath: string;
featureId: string;
className?: string;
/** Whether to show the panel in a compact/minimized state initially */
compact?: boolean;
/** Whether worktrees are enabled - if false, shows diffs from main project */
useWorktrees?: boolean;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: "context" | "addition" | "deletion" | "header";
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case "A":
case "?":
return <FilePlus className="w-4 h-4 text-green-500" />;
case "D":
return <FileX className="w-4 h-4 text-red-500" />;
case "M":
case "U":
return <FilePen className="w-4 h-4 text-amber-500" />;
case "R":
case "C":
return <File className="w-4 h-4 text-blue-500" />;
default:
return <FileText className="w-4 h-4 text-muted-foreground" />;
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case "A":
case "?":
return "bg-green-500/20 text-green-400 border-green-500/30";
case "D":
return "bg-red-500/20 text-red-400 border-red-500/30";
case "M":
case "U":
return "bg-amber-500/20 text-amber-400 border-amber-500/30";
case "R":
case "C":
return "bg-blue-500/20 text-blue-400 border-blue-500/30";
default:
return "bg-muted text-muted-foreground border-border";
}
};
const getStatusDisplayName = (status: string) => {
switch (status) {
case "A":
return "Added";
case "?":
return "Untracked";
case "D":
return "Deleted";
case "M":
return "Modified";
case "U":
return "Updated";
case "R":
return "Renamed";
case "C":
return "Copied";
default:
return "Changed";
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split("\n");
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// New file diff
if (line.startsWith("diff --git")) {
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
// Extract file path from diff header
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : "unknown",
hunks: [],
};
currentHunk = null;
continue;
}
// New file indicator
if (line.startsWith("new file mode")) {
if (currentFile) currentFile.isNew = true;
continue;
}
// Deleted file indicator
if (line.startsWith("deleted file mode")) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
// Renamed file indicator
if (line.startsWith("rename from") || line.startsWith("rename to")) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
// Skip index, ---/+++ lines
if (
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ")
) {
continue;
}
// Hunk header
if (line.startsWith("@@")) {
if (currentHunk && currentFile) {
currentFile.hunks.push(currentHunk);
}
// Parse line numbers from @@ -old,count +new,count @@
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: "header", content: line }],
};
continue;
}
// Diff content lines
if (currentHunk) {
if (line.startsWith("+")) {
currentHunk.lines.push({
type: "addition",
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith("-")) {
currentHunk.lines.push({
type: "deletion",
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(" ") || line === "") {
currentHunk.lines.push({
type: "context",
content: line.substring(1) || "",
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
// Don't forget the last file and hunk
if (currentFile) {
if (currentHunk) {
currentFile.hunks.push(currentHunk);
}
files.push(currentFile);
}
return files;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: "context" | "addition" | "deletion" | "header";
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: "bg-transparent",
addition: "bg-green-500/10",
deletion: "bg-red-500/10",
header: "bg-blue-500/10",
};
const textClass = {
context: "text-foreground-secondary",
addition: "text-green-400",
deletion: "text-red-400",
header: "text-blue-400",
};
const prefix = {
context: " ",
addition: "+",
deletion: "-",
header: "",
};
if (type === "header") {
return (
<div className={cn("px-2 py-1 font-mono text-xs", bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn("flex font-mono text-xs", bgClass[type])}>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.old ?? ""}
</span>
<span className="w-12 flex-shrink-0 text-right pr-2 text-muted-foreground select-none border-r border-border-glass">
{lineNumber?.new ?? ""}
</span>
<span className={cn("w-4 flex-shrink-0 text-center select-none", textClass[type])}>
{prefix[type]}
</span>
<span className={cn("flex-1 px-2 whitespace-pre-wrap break-all", textClass[type])}>
{content || "\u00A0"}
</span>
</div>
);
}
function FileDiffSection({
fileDiff,
isExpanded,
onToggle,
}: {
fileDiff: ParsedFileDiff;
isExpanded: boolean;
onToggle: () => void;
}) {
const additions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "addition").length,
0
);
const deletions = fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === "deletion").length,
0
);
return (
<div className="border border-border rounded-lg overflow-hidden">
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center gap-2 text-left bg-card hover:bg-accent/50 transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
)}
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="flex-1 text-sm font-mono truncate text-foreground">
{fileDiff.filePath}
</span>
<div className="flex items-center gap-2 flex-shrink-0">
{fileDiff.isNew && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-500/20 text-green-400">
new
</span>
)}
{fileDiff.isDeleted && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-500/20 text-red-400">
deleted
</span>
)}
{fileDiff.isRenamed && (
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400">
renamed
</span>
)}
{additions > 0 && (
<span className="text-xs text-green-400">+{additions}</span>
)}
{deletions > 0 && (
<span className="text-xs text-red-400">-{deletions}</span>
)}
</div>
</button>
{isExpanded && (
<div className="bg-background border-t border-border max-h-[400px] overflow-y-auto">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div key={hunkIndex} className="border-b border-border-glass last:border-b-0">
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
</div>
)}
</div>
);
}
export function GitDiffPanel({
projectPath,
featureId,
className,
compact = true,
useWorktrees = false,
}: GitDiffPanelProps) {
const [isExpanded, setIsExpanded] = useState(!compact);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState<string>("");
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(new Set());
const loadDiffs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const api = getElectronAPI();
// Use worktree API if worktrees are enabled, otherwise use git API for main project
if (useWorktrees) {
if (!api?.worktree?.getDiffs) {
throw new Error("Worktree API not available");
}
const result = await api.worktree.getDiffs(projectPath, featureId);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || "");
} else {
setError(result.error || "Failed to load diffs");
}
} else {
// Use git API for main project diffs
if (!api?.git?.getDiffs) {
throw new Error("Git API not available");
}
const result = await api.git.getDiffs(projectPath);
if (result.success) {
setFiles(result.files || []);
setDiffContent(result.diff || "");
} else {
setError(result.error || "Failed to load diffs");
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load diffs");
} finally {
setIsLoading(false);
}
}, [projectPath, featureId, useWorktrees]);
// Load diffs when expanded
useEffect(() => {
if (isExpanded) {
loadDiffs();
}
}, [isExpanded, loadDiffs]);
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
const toggleFile = (filePath: string) => {
setExpandedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
};
const expandAllFiles = () => {
setExpandedFiles(new Set(parsedDiffs.map((d) => d.filePath)));
};
const collapseAllFiles = () => {
setExpandedFiles(new Set());
};
// Total stats
const totalAdditions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "addition").length,
0
),
0
);
const totalDeletions = parsedDiffs.reduce(
(acc, file) =>
acc +
file.hunks.reduce(
(hAcc, hunk) =>
hAcc + hunk.lines.filter((l) => l.type === "deletion").length,
0
),
0
);
return (
<div
className={cn(
"rounded-xl border border-border bg-card backdrop-blur-sm overflow-hidden",
className
)}
data-testid="git-diff-panel"
>
{/* Header */}
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full px-4 py-3 flex items-center justify-between bg-card hover:bg-accent/50 transition-colors text-left"
data-testid="git-diff-panel-toggle"
>
<div className="flex items-center gap-2">
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
<GitBranch className="w-4 h-4 text-brand-500" />
<span className="font-medium text-sm text-foreground">Git Changes</span>
</div>
<div className="flex items-center gap-3 text-xs">
{!isExpanded && files.length > 0 && (
<>
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"}
</span>
{totalAdditions > 0 && (
<span className="text-green-400">+{totalAdditions}</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">-{totalDeletions}</span>
)}
</>
)}
</div>
</button>
{/* Content */}
{isExpanded && (
<div className="border-t border-border">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading changes...</span>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted-foreground">
<AlertCircle className="w-5 h-5 text-amber-500" />
<span className="text-sm">{error}</span>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="mt-2"
>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<span className="text-sm">No changes detected</span>
</div>
) : (
<div className="p-4 space-y-4">
{/* Summary bar */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 flex-wrap">
{(() => {
// Group files by status
const statusGroups = files.reduce((acc, file) => {
const status = file.status;
if (!acc[status]) {
acc[status] = {
count: 0,
statusText: getStatusDisplayName(status),
files: []
};
}
acc[status].count += 1;
acc[status].files.push(file.path);
return acc;
}, {} as Record<string, {count: number, statusText: string, files: string[]}>);
return Object.entries(statusGroups).map(([status, group]) => (
<div
key={status}
className="flex items-center gap-1.5"
title={group.files.join('\n')}
data-testid={`git-status-group-${status.toLowerCase()}`}
>
{getFileIcon(status)}
<span
className={cn(
"text-xs px-1.5 py-0.5 rounded border font-medium",
getStatusBadgeColor(status)
)}
>
{group.count} {group.statusText}
</span>
</div>
));
})()}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={expandAllFiles}
className="text-xs h-7"
>
Expand All
</Button>
<Button
variant="ghost"
size="sm"
onClick={collapseAllFiles}
className="text-xs h-7"
>
Collapse All
</Button>
<Button
variant="ghost"
size="sm"
onClick={loadDiffs}
className="text-xs h-7"
>
<RefreshCw className="w-3 h-3 mr-1" />
Refresh
</Button>
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-4 text-sm">
<span className="text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"} changed
</span>
{totalAdditions > 0 && (
<span className="text-green-400">
+{totalAdditions} additions
</span>
)}
{totalDeletions > 0 && (
<span className="text-red-400">
-{totalDeletions} deletions
</span>
)}
</div>
{/* File diffs */}
<div className="space-y-3">
{parsedDiffs.map((fileDiff) => (
<FileDiffSection
key={fileDiff.filePath}
fileDiff={fileDiff}
isExpanded={expandedFiles.has(fileDiff.filePath)}
onToggle={() => toggleFile(fileDiff.filePath)}
/>
))}
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -13,6 +13,7 @@ import {
Bug,
Info,
FileOutput,
Brain,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
@@ -43,6 +44,8 @@ const getLogIcon = (type: LogEntryType) => {
return <CheckCircle2 className="w-4 h-4" />;
case "warning":
return <AlertTriangle className="w-4 h-4" />;
case "thinking":
return <Brain className="w-4 h-4" />;
case "debug":
return <Bug className="w-4 h-4" />;
default:

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -8,9 +8,12 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, List, FileText } from "lucide-react";
import { Loader2, List, FileText, GitBranch } from "lucide-react";
import { getElectronAPI } from "@/lib/electron";
import { LogViewer } from "@/components/ui/log-viewer";
import { GitDiffPanel } from "@/components/ui/git-diff-panel";
import { useAppStore } from "@/store/app-store";
import type { AutoModeEvent } from "@/types/electron";
interface AgentOutputModalProps {
open: boolean;
@@ -21,7 +24,7 @@ interface AgentOutputModalProps {
onNumberKeyPress?: (key: string) => void;
}
type ViewMode = "parsed" | "raw";
type ViewMode = "parsed" | "raw" | "changes";
export function AgentOutputModal({
open,
@@ -33,9 +36,11 @@ export function AgentOutputModal({
const [output, setOutput] = useState<string>("");
const [isLoading, setIsLoading] = useState(true);
const [viewMode, setViewMode] = useState<ViewMode>("parsed");
const [projectPath, setProjectPath] = useState<string>("");
const scrollRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const projectPathRef = useRef<string>("");
const useWorktrees = useAppStore((state) => state.useWorktrees);
// Auto-scroll to bottom when output changes
useEffect(() => {
@@ -63,6 +68,7 @@ export function AgentOutputModal({
}
projectPathRef.current = currentProject.path;
setProjectPath(currentProject.path);
// Ensure context directory exists
const contextDir = `${currentProject.path}/.automaker/agents-context`;
@@ -113,44 +119,78 @@ export function AgentOutputModal({
if (!api?.autoMode) return;
const unsubscribe = api.autoMode.onEvent((event) => {
// Filter events for this specific feature only
if (event.featureId !== featureId) {
// Filter events for this specific feature only (skip events without featureId)
if ("featureId" in event && event.featureId !== featureId) {
return;
}
let newContent = "";
if (event.type === "auto_mode_progress") {
newContent = event.content || "";
} else if (event.type === "auto_mode_tool") {
const toolName = event.tool || "Unknown Tool";
const toolInput = event.input
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}` : ""
}`;
} else if (event.type === "auto_mode_phase") {
const phaseEmoji =
event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "";
newContent = `\n${phaseEmoji} ${event.message}\n`;
} else if (event.type === "auto_mode_error") {
newContent = `\n❌ Error: ${event.error}\n`;
} else if (event.type === "auto_mode_feature_complete") {
const emoji = event.passes ? "✅" : "⚠️";
newContent = `\n${emoji} Task completed: ${event.message}\n`;
switch (event.type) {
case "auto_mode_progress":
newContent = event.content || "";
break;
case "auto_mode_tool":
const toolName = event.tool || "Unknown Tool";
const toolInput = event.input
? JSON.stringify(event.input, null, 2)
: "";
newContent = `\n🔧 Tool: ${toolName}\n${
toolInput ? `Input: ${toolInput}` : ""
}`;
break;
case "auto_mode_phase":
const phaseEmoji =
event.phase === "planning"
? "📋"
: event.phase === "action"
? "⚡"
: "✅";
newContent = `\n${phaseEmoji} ${event.message}\n`;
break;
case "auto_mode_error":
newContent = `\n❌ Error: ${event.error}\n`;
break;
case "auto_mode_ultrathink_preparation":
// Format thinking level preparation information
let prepContent = `\n🧠 Ultrathink Preparation\n`;
if (event.warnings && event.warnings.length > 0) {
prepContent += `\n⚠ Warnings:\n`;
event.warnings.forEach((warning: string) => {
prepContent += `${warning}\n`;
});
}
if (event.recommendations && event.recommendations.length > 0) {
prepContent += `\n💡 Recommendations:\n`;
event.recommendations.forEach((rec: string) => {
prepContent += `${rec}\n`;
});
}
if (event.estimatedCost !== undefined) {
prepContent += `\n💰 Estimated Cost: ~$${event.estimatedCost.toFixed(2)} per execution\n`;
}
if (event.estimatedTime) {
prepContent += `\n⏱ Estimated Time: ${event.estimatedTime}\n`;
}
newContent = prepContent;
break;
case "auto_mode_feature_complete":
const emoji = event.passes ? "✅" : "⚠️";
newContent = `\n${emoji} Task completed: ${event.message}\n`;
// Close the modal when the feature is verified (passes = true)
if (event.passes) {
// Small delay to show the completion message before closing
setTimeout(() => {
onClose();
}, 1500);
}
// Close the modal when the feature is verified (passes = true)
if (event.passes) {
// Small delay to show the completion message before closing
setTimeout(() => {
onClose();
}, 1500);
}
break;
}
if (newContent) {
@@ -211,25 +251,37 @@ export function AgentOutputModal({
<Loader2 className="w-5 h-5 text-primary animate-spin" />
Agent Output
</DialogTitle>
<div className="flex items-center gap-1 bg-zinc-900/50 rounded-lg p-1">
<div className="flex items-center gap-1 bg-muted rounded-lg p-1">
<button
onClick={() => setViewMode("parsed")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "parsed"
? "bg-primary/20 text-primary shadow-sm"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-parsed"
>
<List className="w-3.5 h-3.5" />
Parsed
Logs
</button>
<button
onClick={() => setViewMode("changes")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "changes"
? "bg-primary/20 text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-changes"
>
<GitBranch className="w-3.5 h-3.5" />
Changes
</button>
<button
onClick={() => setViewMode("raw")}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
viewMode === "raw"
? "bg-primary/20 text-primary shadow-sm"
: "text-zinc-400 hover:text-zinc-200 hover:bg-zinc-800/50"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
data-testid="view-mode-raw"
>
@@ -246,34 +298,55 @@ export function AgentOutputModal({
</DialogDescription>
</DialogHeader>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh]"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading output...
{viewMode === "changes" ? (
<div className="flex-1 overflow-y-auto min-h-[400px] max-h-[60vh]">
{projectPath ? (
<GitDiffPanel
projectPath={projectPath}
featureId={featureId}
compact={false}
useWorktrees={useWorktrees}
className="border-0 rounded-lg"
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading...
</div>
)}
</div>
) : (
<>
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-950 rounded-lg p-4 font-mono text-xs min-h-[400px] max-h-[60vh]"
>
{isLoading && !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
<Loader2 className="w-6 h-6 animate-spin mr-2" />
Loading output...
</div>
) : !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
)}
</div>
) : !output ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
No output yet. The agent will stream output here as it works.
</div>
) : viewMode === "parsed" ? (
<LogViewer output={output} />
) : (
<div className="whitespace-pre-wrap break-words text-zinc-300">
{output}
</div>
)}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? "Auto-scrolling enabled"
: "Scroll to bottom to enable auto-scroll"}
</div>
<div className="text-xs text-muted-foreground text-center flex-shrink-0">
{autoScrollRef.current
? "Auto-scrolling enabled"
: "Scroll to bottom to enable auto-scroll"}
</div>
</>
)}
</DialogContent>
</Dialog>
);

View File

@@ -326,8 +326,8 @@ export function AnalysisView() {
const analyzeStructure = () => {
const structure: string[] = [];
const topLevelDirs = projectAnalysis.fileTree
.filter((n) => n.isDirectory)
.map((n) => n.name);
.filter((n: FileTreeNode) => n.isDirectory)
.map((n: FileTreeNode) => n.name);
for (const dir of topLevelDirs) {
structure.push(` <directory name="${dir}" />`);
@@ -350,14 +350,14 @@ export function AnalysisView() {
<technology_stack>
<languages>
${Object.entries(projectAnalysis.filesByExtension)
.filter(([ext]) =>
.filter(([ext]: [string, number]) =>
["ts", "tsx", "js", "jsx", "py", "go", "rs", "java", "cpp", "c"].includes(
ext
)
)
.sort((a, b) => b[1] - a[1])
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 5)
.map(([ext, count]) => ` <language ext=".${ext}" count="${count}" />`)
.map(([ext, count]: [string, number]) => ` <language ext=".${ext}" count="${count}" />`)
.join("\n")}
</languages>
<frameworks>
@@ -375,10 +375,10 @@ ${analyzeStructure()}
<file_breakdown>
${Object.entries(projectAnalysis.filesByExtension)
.sort((a, b) => b[1] - a[1])
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 10)
.map(
([ext, count]) =>
([ext, count]: [string, number]) =>
` <extension type="${
ext.startsWith("(") ? ext : "." + ext
}" count="${count}" />`
@@ -465,11 +465,11 @@ ${Object.entries(projectAnalysis.filesByExtension)
const detectFeatures = () => {
const extensions = projectAnalysis.filesByExtension;
const topLevelDirs = projectAnalysis.fileTree
.filter((n) => n.isDirectory)
.map((n) => n.name.toLowerCase());
.filter((n: FileTreeNode) => n.isDirectory)
.map((n: FileTreeNode) => n.name.toLowerCase());
const topLevelFiles = projectAnalysis.fileTree
.filter((n) => !n.isDirectory)
.map((n) => n.name.toLowerCase());
.filter((n: FileTreeNode) => !n.isDirectory)
.map((n: FileTreeNode) => n.name.toLowerCase());
// Check for test directories and files
const hasTests =
@@ -840,7 +840,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
</div>
{node.isDirectory && isExpanded && node.children && (
<div>
{node.children.map((child) => renderNode(child, depth + 1))}
{node.children.map((child: FileTreeNode) => renderNode(child, depth + 1))}
</div>
)}
</div>
@@ -964,9 +964,9 @@ ${Object.entries(projectAnalysis.filesByExtension)
<CardContent>
<div className="space-y-2">
{Object.entries(projectAnalysis.filesByExtension)
.sort((a, b) => b[1] - a[1])
.sort((a: [string, number], b: [string, number]) => b[1] - a[1])
.slice(0, 15)
.map(([ext, count]) => (
.map(([ext, count]: [string, number]) => (
<div key={ext} className="flex justify-between text-sm">
<span className="text-muted-foreground font-mono">
{ext.startsWith("(") ? ext : `.${ext}`}
@@ -1107,7 +1107,7 @@ ${Object.entries(projectAnalysis.filesByExtension)
data-testid="analysis-file-tree"
>
<div className="p-2">
{projectAnalysis.fileTree.map((node) => renderNode(node))}
{projectAnalysis.fileTree.map((node: FileTreeNode) => renderNode(node))}
</div>
</CardContent>
</Card>

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,9 @@ import {
FileText,
MoreVertical,
AlertCircle,
GitBranch,
Undo2,
GitMerge,
} from "lucide-react";
import { CountUpTimer } from "@/components/ui/count-up-timer";
import { getElectronAPI } from "@/lib/electron";
@@ -59,6 +62,12 @@ import {
DEFAULT_MODEL,
} from "@/lib/agent-context-parser";
import { Markdown } from "@/components/ui/markdown";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
interface KanbanCardProps {
feature: Feature;
@@ -72,6 +81,8 @@ interface KanbanCardProps {
onMoveBackToInProgress?: () => void;
onFollowUp?: () => void;
onCommit?: () => void;
onRevert?: () => void;
onMerge?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -93,6 +104,8 @@ export function KanbanCard({
onMoveBackToInProgress,
onFollowUp,
onCommit,
onRevert,
onMerge,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -101,9 +114,13 @@ export function KanbanCard({
}: KanbanCardProps) {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isRevertDialogOpen, setIsRevertDialogOpen] = useState(false);
const [agentInfo, setAgentInfo] = useState<AgentTaskInfo | null>(null);
const { kanbanCardDetailLevel } = useAppStore();
// Check if feature has worktree
const hasWorktree = !!feature.branchName;
// Helper functions to check what should be shown based on detail level
const showSteps =
kanbanCardDetailLevel === "standard" ||
@@ -196,7 +213,7 @@ export function KanbanCard({
ref={setNodeRef}
style={style}
className={cn(
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative",
"cursor-grab active:cursor-grabbing transition-all backdrop-blur-sm border-border relative kanban-card-content",
isDragging && "opacity-50 scale-105 shadow-lg",
isCurrentAutoTask &&
"border-running-indicator border-2 shadow-running-indicator/50 shadow-lg animate-pulse",
@@ -246,7 +263,43 @@ export function KanbanCard({
<span>Errored</span>
</div>
)}
<CardHeader className="p-3 pb-2">
{/* Branch badge - show when feature has a worktree */}
{hasWorktree && !isCurrentAutoTask && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
"absolute px-1.5 py-0.5 text-[10px] font-medium rounded flex items-center gap-1 z-10 cursor-default",
"bg-purple-500/20 border border-purple-500/50 text-purple-400",
// Position below error badge if present, otherwise use normal position
feature.error || feature.skipTests
? "top-8 left-2"
: shortcutKey
? "top-2 left-10"
: "top-2 left-2"
)}
data-testid={`branch-badge-${feature.id}`}
>
<GitBranch className="w-3 h-3 shrink-0" />
<span className="truncate max-w-[80px]">{feature.branchName?.replace("feature/", "")}</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[300px]">
<p className="font-mono text-xs break-all">{feature.branchName}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<CardHeader
className={cn(
"p-3 pb-2 block", // Reset grid layout to block for custom kanban card layout
// Add extra top padding when badges are present to prevent text overlap
(feature.skipTests || feature.error || shortcutKey) && "pt-10",
// Add even more top padding when both badges and branch are shown
hasWorktree && (feature.skipTests || feature.error) && "pt-14"
)}
>
{isCurrentAutoTask && (
<div className="absolute top-2 right-2 flex items-center justify-center gap-2 bg-running-indicator/20 border border-running-indicator rounded px-2 py-0.5">
<Loader2 className="w-4 h-4 text-running-indicator animate-spin" />
@@ -320,11 +373,11 @@ export function KanbanCard({
<GripVertical className="w-4 h-4 text-muted-foreground" />
</div>
)}
<div className="flex-1 min-w-0">
<CardTitle className="text-sm leading-tight">
<div className="flex-1 min-w-0 overflow-hidden">
<CardTitle className="text-sm leading-tight break-words hyphens-auto line-clamp-3 overflow-hidden">
{feature.description}
</CardTitle>
<CardDescription className="text-xs mt-1">
<CardDescription className="text-xs mt-1 truncate">
{feature.category}
</CardDescription>
</div>
@@ -344,7 +397,7 @@ export function KanbanCard({
) : (
<Circle className="w-3 h-3 mt-0.5 shrink-0" />
)}
<span className="truncate">{step}</span>
<span className="break-words hyphens-auto line-clamp-2 leading-relaxed">{step}</span>
</div>
))}
{feature.steps.length > 3 && (
@@ -358,13 +411,13 @@ export function KanbanCard({
{/* Agent Info Panel - shows for in_progress, waiting_approval, verified */}
{/* Detailed mode: Show all agent info */}
{showAgentInfo && feature.status !== "backlog" && agentInfo && (
<div className="mb-3 space-y-2">
<div className="mb-3 space-y-2 overflow-hidden">
{/* Model & Phase */}
<div className="flex items-center gap-2 text-xs">
<div className="flex items-center gap-2 text-xs flex-wrap">
<div className="flex items-center gap-1 text-cyan-400">
<Cpu className="w-3 h-3" />
<span className="font-medium">
{formatModelName(DEFAULT_MODEL)}
{formatModelName(feature.model ?? DEFAULT_MODEL)}
</span>
</div>
{agentInfo.currentPhase && (
@@ -408,15 +461,15 @@ export function KanbanCard({
) : todo.status === "in_progress" ? (
<Loader2 className="w-2.5 h-2.5 text-amber-400 animate-spin shrink-0" />
) : (
<Circle className="w-2.5 h-2.5 text-zinc-500 shrink-0" />
<Circle className="w-2.5 h-2.5 text-muted-foreground shrink-0" />
)}
<span
className={cn(
"truncate",
"break-words hyphens-auto line-clamp-2 leading-relaxed",
todo.status === "completed" &&
"text-zinc-500 line-through",
"text-muted-foreground line-through",
todo.status === "in_progress" && "text-amber-400",
todo.status === "pending" && "text-zinc-400"
todo.status === "pending" && "text-foreground-secondary"
)}
>
{todo.content}
@@ -437,25 +490,25 @@ export function KanbanCard({
feature.status === "verified") && (
<>
{(feature.summary || summary || agentInfo.summary) && (
<div className="space-y-1 pt-1 border-t border-white/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1 text-[10px] text-green-400">
<Sparkles className="w-3 h-3" />
<span>Summary</span>
<div className="space-y-1 pt-1 border-t border-border-glass overflow-hidden">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1 text-[10px] text-green-400 min-w-0">
<Sparkles className="w-3 h-3 shrink-0" />
<span className="truncate">Summary</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setIsSummaryDialogOpen(true);
}}
className="p-0.5 rounded hover:bg-white/10 transition-colors text-zinc-500 hover:text-zinc-300"
className="p-0.5 rounded hover:bg-accent transition-colors text-muted-foreground hover:text-foreground shrink-0"
title="View full summary"
data-testid={`expand-summary-${feature.id}`}
>
<Expand className="w-3 h-3" />
</button>
</div>
<p className="text-[10px] text-zinc-400 line-clamp-3">
<p className="text-[10px] text-foreground-secondary line-clamp-3 break-words hyphens-auto leading-relaxed overflow-hidden">
{feature.summary || summary || agentInfo.summary}
</p>
</div>
@@ -465,7 +518,7 @@ export function KanbanCard({
!summary &&
!agentInfo.summary &&
agentInfo.toolCallCount > 0 && (
<div className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-white/5">
<div className="flex items-center gap-2 text-[10px] text-muted-foreground pt-1 border-t border-border-glass">
<span className="flex items-center gap-1">
<Wrench className="w-2.5 h-2.5" />
{agentInfo.toolCallCount} tool calls
@@ -609,24 +662,65 @@ export function KanbanCard({
)}
{!isCurrentAutoTask && feature.status === "waiting_approval" && (
<>
{/* Revert button - only show when worktree exists (icon only to save space) */}
{hasWorktree && onRevert && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-red-400 hover:text-red-300 hover:bg-red-500/20 shrink-0"
onClick={(e) => {
e.stopPropagation();
setIsRevertDialogOpen(true);
}}
data-testid={`revert-${feature.id}`}
>
<Undo2 className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Revert changes</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Follow-up prompt button */}
{onFollowUp && (
<Button
variant="secondary"
size="sm"
className="flex-1 h-7 text-xs"
className="flex-1 h-7 text-xs min-w-0"
onClick={(e) => {
e.stopPropagation();
onFollowUp();
}}
data-testid={`follow-up-${feature.id}`}
>
<MessageSquare className="w-3 h-3 mr-1" />
Follow-up
<MessageSquare className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Follow-up</span>
</Button>
)}
{/* Commit and verify button */}
{onCommit && (
{/* Merge button - only show when worktree exists */}
{hasWorktree && onMerge && (
<Button
variant="default"
size="sm"
className="flex-1 h-7 text-xs bg-purple-600 hover:bg-purple-700 min-w-0"
onClick={(e) => {
e.stopPropagation();
onMerge();
}}
data-testid={`merge-${feature.id}`}
title="Merge changes into main branch"
>
<GitMerge className="w-3 h-3 mr-1 shrink-0" />
<span className="truncate">Merge</span>
</Button>
)}
{/* Commit and verify button - show when no worktree */}
{!hasWorktree && onCommit && (
<Button
variant="default"
size="sm"
@@ -711,7 +805,7 @@ export function KanbanCard({
: feature.description}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto p-4 bg-zinc-900/50 rounded-lg border border-white/10">
<div className="flex-1 overflow-y-auto p-4 bg-card rounded-lg border border-border">
<Markdown>
{feature.summary ||
summary ||
@@ -730,6 +824,49 @@ export function KanbanCard({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Revert Confirmation Dialog */}
<Dialog open={isRevertDialogOpen} onOpenChange={setIsRevertDialogOpen}>
<DialogContent data-testid="revert-confirmation-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-400">
<Undo2 className="w-5 h-5" />
Revert Changes
</DialogTitle>
<DialogDescription>
This will discard all changes made by the agent and move the feature back to the backlog.
{feature.branchName && (
<span className="block mt-2 font-medium">
Branch <code className="bg-muted px-1 py-0.5 rounded">{feature.branchName}</code> will be deleted.
</span>
)}
<span className="block mt-2 text-red-400 font-medium">
This action cannot be undone.
</span>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setIsRevertDialogOpen(false)}
data-testid="cancel-revert-button"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => {
setIsRevertDialogOpen(false);
onRevert?.();
}}
data-testid="confirm-revert-button"
>
<Undo2 className="w-4 h-4 mr-2" />
Revert Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -0,0 +1,691 @@
"use client";
import { useState, useMemo, useCallback, useEffect } from "react";
import { useAppStore, AIProfile, AgentModel, ThinkingLevel, ModelProvider } from "@/store/app-store";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn, modelSupportsThinking } from "@/lib/utils";
import {
useKeyboardShortcuts,
ACTION_SHORTCUTS,
KeyboardShortcut,
} from "@/hooks/use-keyboard-shortcuts";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
UserCircle,
Plus,
Pencil,
Trash2,
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
GripVertical,
Lock,
Check,
} from "lucide-react";
import { toast } from "sonner";
import {
DndContext,
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// Icon mapping for profiles
const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
Brain,
Zap,
Scale,
Cpu,
Rocket,
Sparkles,
};
// Available icons for selection
const ICON_OPTIONS = [
{ name: "Brain", icon: Brain },
{ name: "Zap", icon: Zap },
{ name: "Scale", icon: Scale },
{ name: "Cpu", icon: Cpu },
{ name: "Rocket", icon: Rocket },
{ name: "Sparkles", icon: Sparkles },
];
// Model options for the form
const CLAUDE_MODELS: { id: AgentModel; label: string }[] = [
{ id: "haiku", label: "Claude Haiku" },
{ id: "sonnet", label: "Claude Sonnet" },
{ id: "opus", label: "Claude Opus" },
];
const CODEX_MODELS: { id: AgentModel; label: string }[] = [
{ id: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
{ id: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
{ id: "gpt-5.1-codex-mini", label: "GPT-5.1 Codex Mini" },
{ id: "gpt-5.1", label: "GPT-5.1" },
];
const THINKING_LEVELS: { id: ThinkingLevel; label: string }[] = [
{ id: "none", label: "None" },
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "ultrathink", label: "Ultrathink" },
];
// Helper to determine provider from model
function getProviderFromModel(model: AgentModel): ModelProvider {
if (model.startsWith("gpt")) {
return "codex";
}
return "claude";
}
// Sortable Profile Card Component
function SortableProfileCard({
profile,
onEdit,
onDelete,
}: {
profile: AIProfile;
onEdit: () => void;
onDelete: () => void;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: profile.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const IconComponent = profile.icon ? PROFILE_ICONS[profile.icon] : Brain;
const isCodex = profile.provider === "codex";
return (
<div
ref={setNodeRef}
style={style}
className={cn(
"group relative flex items-start gap-4 p-4 rounded-xl border bg-card transition-all",
isDragging && "shadow-lg",
profile.isBuiltIn
? "border-border/50"
: "border-border hover:border-primary/50 hover:shadow-sm"
)}
data-testid={`profile-card-${profile.id}`}
>
{/* Drag Handle */}
<button
{...attributes}
{...listeners}
className="p-1 rounded hover:bg-accent cursor-grab active:cursor-grabbing flex-shrink-0 mt-1"
data-testid={`profile-drag-handle-${profile.id}`}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4 text-muted-foreground" />
</button>
{/* Icon */}
<div
className={cn(
"flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center",
isCodex ? "bg-emerald-500/10" : "bg-primary/10"
)}
>
{IconComponent && (
<IconComponent
className={cn(
"w-5 h-5",
isCodex ? "text-emerald-500" : "text-primary"
)}
/>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-foreground">{profile.name}</h3>
{profile.isBuiltIn && (
<span className="flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded-full bg-muted text-muted-foreground">
<Lock className="w-2.5 h-2.5" />
Built-in
</span>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{profile.description}
</p>
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full border",
isCodex
? "border-emerald-500/30 text-emerald-600 dark:text-emerald-400 bg-emerald-500/10"
: "border-primary/30 text-primary bg-primary/10"
)}
>
{profile.model}
</span>
{profile.thinkingLevel !== "none" && (
<span className="text-xs px-2 py-0.5 rounded-full border border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10">
{profile.thinkingLevel}
</span>
)}
</div>
</div>
{/* Actions */}
{!profile.isBuiltIn && (
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="h-8 w-8 p-0"
data-testid={`edit-profile-${profile.id}`}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
data-testid={`delete-profile-${profile.id}`}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
);
}
// Profile Form Component
function ProfileForm({
profile,
onSave,
onCancel,
isEditing,
}: {
profile: Partial<AIProfile>;
onSave: (profile: Omit<AIProfile, "id">) => void;
onCancel: () => void;
isEditing: boolean;
}) {
const [formData, setFormData] = useState({
name: profile.name || "",
description: profile.description || "",
model: profile.model || ("opus" as AgentModel),
thinkingLevel: profile.thinkingLevel || ("none" as ThinkingLevel),
icon: profile.icon || "Brain",
});
const provider = getProviderFromModel(formData.model);
const supportsThinking = modelSupportsThinking(formData.model);
const handleModelChange = (model: AgentModel) => {
const newProvider = getProviderFromModel(model);
setFormData({
...formData,
model,
// Reset thinking level when switching to Codex (doesn't support thinking)
thinkingLevel: newProvider === "codex" ? "none" : formData.thinkingLevel,
});
};
const handleSubmit = () => {
if (!formData.name.trim()) {
toast.error("Please enter a profile name");
return;
}
onSave({
name: formData.name.trim(),
description: formData.description.trim(),
model: formData.model,
thinkingLevel: supportsThinking ? formData.thinkingLevel : "none",
provider,
isBuiltIn: false,
icon: formData.icon,
});
};
return (
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Heavy Task, Quick Fix"
data-testid="profile-name-input"
/>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="profile-description">Description</Label>
<Textarea
id="profile-description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Describe when to use this profile..."
rows={2}
data-testid="profile-description-input"
/>
</div>
{/* Icon Selection */}
<div className="space-y-2">
<Label>Icon</Label>
<div className="flex gap-2 flex-wrap">
{ICON_OPTIONS.map(({ name, icon: Icon }) => (
<button
key={name}
type="button"
onClick={() => setFormData({ ...formData, icon: name })}
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center border transition-colors",
formData.icon === name
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`icon-select-${name}`}
>
<Icon className="w-5 h-5" />
</button>
))}
</div>
</div>
{/* Model Selection - Claude */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-primary" />
Claude Models
</Label>
<div className="flex gap-2 flex-wrap">
{CLAUDE_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-primary text-primary-foreground border-primary"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${id}`}
>
{label.replace("Claude ", "")}
</button>
))}
</div>
</div>
{/* Model Selection - Codex */}
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-500" />
Codex Models
</Label>
<div className="flex gap-2 flex-wrap">
{CODEX_MODELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => handleModelChange(id)}
className={cn(
"flex-1 min-w-[100px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.model === id
? "bg-emerald-600 text-white border-emerald-500"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`model-select-${id}`}
>
{label.replace("GPT-5.1 ", "").replace("Codex ", "")}
</button>
))}
</div>
</div>
{/* Thinking Level - Only for Claude models */}
{supportsThinking && (
<div className="space-y-2">
<Label className="flex items-center gap-2">
<Brain className="w-4 h-4 text-amber-500" />
Thinking Level
</Label>
<div className="flex gap-2 flex-wrap">
{THINKING_LEVELS.map(({ id, label }) => (
<button
key={id}
type="button"
onClick={() => {
setFormData({ ...formData, thinkingLevel: id });
if (id === "ultrathink") {
toast.warning("Ultrathink uses extensive reasoning", {
description:
"Best for complex architecture, migrations, or deep debugging (~$0.48/task).",
duration: 4000,
});
}
}}
className={cn(
"flex-1 min-w-[70px] px-3 py-2 rounded-md border text-sm font-medium transition-colors",
formData.thinkingLevel === id
? "bg-amber-500 text-white border-amber-400"
: "bg-background hover:bg-accent border-input"
)}
data-testid={`thinking-select-${id}`}
>
{label}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
Higher levels give more time to reason through complex problems.
</p>
</div>
)}
{/* Actions */}
<DialogFooter className="pt-4">
<Button variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button onClick={handleSubmit} data-testid="save-profile-button">
{isEditing ? "Save Changes" : "Create Profile"}
</Button>
</DialogFooter>
</div>
);
}
export function ProfilesView() {
const { aiProfiles, addAIProfile, updateAIProfile, removeAIProfile, reorderAIProfiles } =
useAppStore();
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingProfile, setEditingProfile] = useState<AIProfile | null>(null);
// Sensors for drag-and-drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 5,
},
})
);
// Separate built-in and custom profiles
const builtInProfiles = useMemo(
() => aiProfiles.filter((p) => p.isBuiltIn),
[aiProfiles]
);
const customProfiles = useMemo(
() => aiProfiles.filter((p) => !p.isBuiltIn),
[aiProfiles]
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = aiProfiles.findIndex((p) => p.id === active.id);
const newIndex = aiProfiles.findIndex((p) => p.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
reorderAIProfiles(oldIndex, newIndex);
}
}
},
[aiProfiles, reorderAIProfiles]
);
const handleAddProfile = (profile: Omit<AIProfile, "id">) => {
addAIProfile(profile);
setShowAddDialog(false);
toast.success("Profile created", {
description: `Created "${profile.name}" profile`,
});
};
const handleUpdateProfile = (profile: Omit<AIProfile, "id">) => {
if (editingProfile) {
updateAIProfile(editingProfile.id, profile);
setEditingProfile(null);
toast.success("Profile updated", {
description: `Updated "${profile.name}" profile`,
});
}
};
const handleDeleteProfile = (profile: AIProfile) => {
if (profile.isBuiltIn) return;
removeAIProfile(profile.id);
toast.success("Profile deleted", {
description: `Deleted "${profile.name}" profile`,
});
};
// Build keyboard shortcuts for profiles view
const profilesShortcuts: KeyboardShortcut[] = useMemo(() => {
const shortcuts: KeyboardShortcut[] = [];
// Add profile shortcut - when in profiles view
shortcuts.push({
key: ACTION_SHORTCUTS.addProfile,
action: () => setShowAddDialog(true),
description: "Create new profile",
});
return shortcuts;
}, []);
// Register keyboard shortcuts for profiles view
useKeyboardShortcuts(profilesShortcuts);
return (
<div
className="flex-1 flex flex-col overflow-hidden content-bg"
data-testid="profiles-view"
>
{/* Header Section */}
<div className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
<div className="px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-linear-to-br from-brand-500 to-brand-600 shadow-lg shadow-brand-500/20 flex items-center justify-center">
<UserCircle className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-2xl font-bold text-foreground">
AI Profiles
</h1>
<p className="text-sm text-muted-foreground">
Create and manage model configuration presets
</p>
</div>
</div>
<Button onClick={() => setShowAddDialog(true)} data-testid="add-profile-button" className="relative">
<Plus className="w-4 h-4 mr-2" />
New Profile
<span className="hidden lg:flex items-center justify-center ml-2 px-2 py-0.5 text-[10px] font-mono rounded bg-white/5 border border-white/10 text-zinc-500">
{ACTION_SHORTCUTS.addProfile}
</span>
</Button>
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-4xl mx-auto space-y-8">
{/* Custom Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Custom Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-primary/10 text-primary">
{customProfiles.length}
</span>
</div>
{customProfiles.length === 0 ? (
<div className="rounded-xl border border-dashed border-border p-8 text-center">
<Sparkles className="w-10 h-10 text-muted-foreground mx-auto mb-3 opacity-50" />
<p className="text-muted-foreground">
No custom profiles yet. Create one to get started!
</p>
<Button
variant="outline"
className="mt-4"
onClick={() => setShowAddDialog(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</Button>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={customProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{customProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => setEditingProfile(profile)}
onDelete={() => handleDeleteProfile(profile)}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
{/* Built-in Profiles Section */}
<div>
<div className="flex items-center gap-2 mb-4">
<h2 className="text-lg font-semibold text-foreground">
Built-in Profiles
</h2>
<span className="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{builtInProfiles.length}
</span>
</div>
<p className="text-sm text-muted-foreground mb-4">
Pre-configured profiles for common use cases. These cannot be
edited or deleted.
</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={builtInProfiles.map((p) => p.id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-3">
{builtInProfiles.map((profile) => (
<SortableProfileCard
key={profile.id}
profile={profile}
onEdit={() => {}}
onDelete={() => {}}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
</div>
</div>
{/* Add Profile Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent data-testid="add-profile-dialog">
<DialogHeader>
<DialogTitle>Create New Profile</DialogTitle>
<DialogDescription>
Define a reusable model configuration preset.
</DialogDescription>
</DialogHeader>
<ProfileForm
profile={{}}
onSave={handleAddProfile}
onCancel={() => setShowAddDialog(false)}
isEditing={false}
/>
</DialogContent>
</Dialog>
{/* Edit Profile Dialog */}
<Dialog
open={!!editingProfile}
onOpenChange={() => setEditingProfile(null)}
>
<DialogContent data-testid="edit-profile-dialog">
<DialogHeader>
<DialogTitle>Edit Profile</DialogTitle>
<DialogDescription>Modify your profile settings.</DialogDescription>
</DialogHeader>
{editingProfile && (
<ProfileForm
profile={editingProfile}
onSave={handleUpdateProfile}
onCancel={() => setEditingProfile(null)}
isEditing={true}
/>
)}
</DialogContent>
</Dialog>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -275,7 +275,8 @@ export function useElectronAgent({
setIsProcessing(false);
setError(event.error);
if (event.message) {
setMessages((prev) => [...prev, event.message]);
const errorMessage = event.message;
setMessages((prev) => [...prev, errorMessage]);
}
break;
}
@@ -409,5 +410,8 @@ export function useElectronAgent({
stopExecution,
clearHistory,
error,
queuedMessages,
isQueueProcessing: isProcessingQueue,
clearMessageQueue: clearQueue,
};
}

View File

@@ -106,6 +106,7 @@ export const NAV_SHORTCUTS: Record<string, string> = {
context: "C", // C for Context
tools: "T", // T for Tools
settings: "S", // S for Settings
profiles: "M", // M for Models/profiles
};
/**
@@ -127,4 +128,5 @@ export const ACTION_SHORTCUTS: Record<string, string> = {
projectPicker: "P", // P for Project picker
cyclePrevProject: "Q", // Q for previous project (cycle back through MRU)
cycleNextProject: "E", // E for next project (cycle forward through MRU)
addProfile: "N", // N for New profile (when in profiles view)
};

View File

@@ -0,0 +1,54 @@
import { useState, useEffect } from "react";
export interface WindowState {
isMaximized: boolean;
windowWidth: number;
windowHeight: number;
}
/**
* Hook to track window state (dimensions and maximized status)
* For Electron apps, considers window maximized if width > 1400px
* Also listens for window resize events to update state
*/
export function useWindowState(): WindowState {
const [windowState, setWindowState] = useState<WindowState>(() => {
if (typeof window === "undefined") {
return { isMaximized: false, windowWidth: 0, windowHeight: 0 };
}
const width = window.innerWidth;
const height = window.innerHeight;
return {
isMaximized: width > 1400,
windowWidth: width,
windowHeight: height,
};
});
useEffect(() => {
if (typeof window === "undefined") return;
const updateWindowState = () => {
const width = window.innerWidth;
const height = window.innerHeight;
setWindowState({
isMaximized: width > 1400,
windowWidth: width,
windowHeight: height,
});
};
// Set initial state
updateWindowState();
// Listen for resize events
window.addEventListener("resize", updateWindowState);
return () => {
window.removeEventListener("resize", updateWindowState);
};
}, []);
return windowState;
}

View File

@@ -34,8 +34,8 @@ export const DEFAULT_MODEL = "claude-opus-4-5-20251101";
*/
export function formatModelName(model: string): string {
if (model.includes("opus")) return "Opus 4.5";
if (model.includes("sonnet")) return "Sonnet 4";
if (model.includes("haiku")) return "Haiku 3.5";
if (model.includes("sonnet")) return "Sonnet 4.5";
if (model.includes("haiku")) return "Haiku 4.5";
return model.split("-").slice(1, 3).join(" ");
}

View File

@@ -41,22 +41,8 @@ export interface StatResult {
error?: string;
}
// Auto Mode types
export type AutoModePhase = "planning" | "action" | "verification";
export interface AutoModeEvent {
type: "auto_mode_feature_start" | "auto_mode_progress" | "auto_mode_tool" | "auto_mode_feature_complete" | "auto_mode_error" | "auto_mode_complete" | "auto_mode_phase";
featureId?: string;
projectId?: string;
feature?: object;
content?: string;
tool?: string;
input?: unknown;
passes?: boolean;
message?: string;
error?: string;
phase?: AutoModePhase;
}
// Auto Mode types - Import from electron.d.ts to avoid duplication
import type { AutoModeEvent, ModelDefinition, ProviderStatus, WorktreeAPI, GitAPI, WorktreeInfo, WorktreeStatus, FileDiffsResult, FileDiffResult, FileStatus } from "@/types/electron";
// Feature Suggestions types
export interface FeatureSuggestion {
@@ -104,7 +90,7 @@ export interface AutoModeAPI {
stop: () => Promise<{ success: boolean; error?: string }>;
stopFeature: (featureId: string) => Promise<{ success: boolean; error?: string }>;
status: () => Promise<{ success: boolean; isRunning?: boolean; currentFeatureId?: string | null; runningFeatures?: string[]; error?: string }>;
runFeature: (projectPath: string, featureId: string) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
verifyFeature: (projectPath: string, featureId: string) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
resumeFeature: (projectPath: string, featureId: string) => Promise<{ success: boolean; passes?: boolean; error?: string }>;
contextExists: (projectPath: string, featureId: string) => Promise<{ success: boolean; exists?: boolean; error?: string }>;
@@ -133,19 +119,63 @@ export interface ElectronAPI {
deleteFile: (filePath: string) => Promise<WriteResult>;
trashItem?: (filePath: string) => Promise<WriteResult>;
getPath: (name: string) => Promise<string>;
saveImageToTemp?: (data: string, filename: string, mimeType: string) => Promise<SaveImageResult>;
autoMode?: AutoModeAPI;
saveImageToTemp?: (data: string, filename: string, mimeType: string, projectPath?: string) => Promise<SaveImageResult>;
checkClaudeCli?: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}>;
checkCodexCli?: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}>;
model?: {
getAvailable: () => Promise<{
success: boolean;
models?: ModelDefinition[];
error?: string;
}>;
checkProviders: () => Promise<{
success: boolean;
providers?: Record<string, ProviderStatus>;
error?: string;
}>;
};
testOpenAIConnection?: (apiKey?: string) => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
worktree?: WorktreeAPI;
git?: GitAPI;
suggestions?: SuggestionsAPI;
specRegeneration?: SpecRegenerationAPI;
}
// Augment global Window interface
declare global {
interface Window {
electronAPI: ElectronAPI | undefined;
isElectron: boolean | undefined;
}
}
// Note: Window interface is declared in @/types/electron.d.ts
// Do not redeclare here to avoid type conflicts
// Mock data for web development
const mockFeatures = [
@@ -394,12 +424,13 @@ export const getElectronAPI = (): ElectronAPI => {
},
// Save image to temp directory
saveImageToTemp: async (data: string, filename: string, mimeType: string) => {
// Generate a mock temp file path
saveImageToTemp: async (data: string, filename: string, mimeType: string, projectPath?: string) => {
// Generate a mock temp file path - use projectPath if provided
const timestamp = Date.now();
const ext = mimeType.split("/")[1] || "png";
const safeName = filename.replace(/[^a-zA-Z0-9.-]/g, "_");
const tempFilePath = `/tmp/automaker-images/${timestamp}_${safeName}`;
const tempFilePath = projectPath
? `${projectPath}/.automaker/images/${timestamp}_${safeName}`
: `/tmp/automaker-images/${timestamp}_${safeName}`;
// Store the image data in mock file system for testing
mockFileSystem[tempFilePath] = data;
@@ -408,9 +439,37 @@ export const getElectronAPI = (): ElectronAPI => {
return { success: true, path: tempFilePath };
},
checkClaudeCli: async () => ({
success: false,
status: "not_installed",
recommendation: "Claude CLI checks are unavailable in the web preview.",
}),
checkCodexCli: async () => ({
success: false,
status: "not_installed",
recommendation: "Codex CLI checks are unavailable in the web preview.",
}),
model: {
getAvailable: async () => ({ success: true, models: [] }),
checkProviders: async () => ({ success: true, providers: {} }),
},
testOpenAIConnection: async () => ({
success: false,
error: "OpenAI connection test is only available in the Electron app.",
}),
// Mock Auto Mode API
autoMode: createMockAutoModeAPI(),
// Mock Worktree API
worktree: createMockWorktreeAPI(),
// Mock Git API (for non-worktree operations)
git: createMockGitAPI(),
// Mock Suggestions API
suggestions: createMockSuggestionsAPI(),
@@ -419,6 +478,99 @@ export const getElectronAPI = (): ElectronAPI => {
};
};
// Mock Worktree API implementation
function createMockWorktreeAPI(): WorktreeAPI {
return {
revertFeature: async (projectPath: string, featureId: string) => {
console.log("[Mock] Reverting feature:", { projectPath, featureId });
return { success: true, removedPath: `/mock/worktree/${featureId}` };
},
mergeFeature: async (projectPath: string, featureId: string, options?: object) => {
console.log("[Mock] Merging feature:", { projectPath, featureId, options });
return { success: true, mergedBranch: `feature/${featureId}` };
},
getInfo: async (projectPath: string, featureId: string) => {
console.log("[Mock] Getting worktree info:", { projectPath, featureId });
return {
success: true,
worktreePath: `/mock/worktrees/${featureId}`,
branchName: `feature/${featureId}`,
head: "abc1234",
};
},
getStatus: async (projectPath: string, featureId: string) => {
console.log("[Mock] Getting worktree status:", { projectPath, featureId });
return {
success: true,
modifiedFiles: 3,
files: ["src/feature.ts", "tests/feature.spec.ts", "README.md"],
diffStat: " 3 files changed, 50 insertions(+), 10 deletions(-)",
recentCommits: [
"abc1234 feat: implement feature",
"def5678 test: add tests for feature",
],
};
},
list: async (projectPath: string) => {
console.log("[Mock] Listing worktrees:", { projectPath });
return { success: true, worktrees: [] };
},
getDiffs: async (projectPath: string, featureId: string) => {
console.log("[Mock] Getting file diffs:", { projectPath, featureId });
return {
success: true,
diff: "diff --git a/src/feature.ts b/src/feature.ts\n+++ new file\n@@ -0,0 +1,10 @@\n+export function feature() {\n+ return 'hello';\n+}",
files: [
{ status: "A", path: "src/feature.ts", statusText: "Added" },
{ status: "M", path: "README.md", statusText: "Modified" },
],
hasChanges: true,
};
},
getFileDiff: async (projectPath: string, featureId: string, filePath: string) => {
console.log("[Mock] Getting file diff:", { projectPath, featureId, filePath });
return {
success: true,
diff: `diff --git a/${filePath} b/${filePath}\n+++ new file\n@@ -0,0 +1,5 @@\n+// New content`,
filePath,
};
},
};
}
// Mock Git API implementation (for non-worktree operations)
function createMockGitAPI(): GitAPI {
return {
getDiffs: async (projectPath: string) => {
console.log("[Mock] Getting git diffs for project:", { projectPath });
return {
success: true,
diff: "diff --git a/src/feature.ts b/src/feature.ts\n+++ new file\n@@ -0,0 +1,10 @@\n+export function feature() {\n+ return 'hello';\n+}",
files: [
{ status: "A", path: "src/feature.ts", statusText: "Added" },
{ status: "M", path: "README.md", statusText: "Modified" },
],
hasChanges: true,
};
},
getFileDiff: async (projectPath: string, filePath: string) => {
console.log("[Mock] Getting git file diff:", { projectPath, filePath });
return {
success: true,
diff: `diff --git a/${filePath} b/${filePath}\n+++ new file\n@@ -0,0 +1,5 @@\n+// New content`,
filePath,
};
},
};
}
// Mock Auto Mode state and implementation
let mockAutoModeRunning = false;
let mockRunningFeatures = new Set<string>(); // Track multiple concurrent feature verifications
@@ -487,11 +639,12 @@ function createMockAutoModeAPI(): AutoModeAPI {
};
},
runFeature: async (projectPath: string, featureId: string) => {
runFeature: async (projectPath: string, featureId: string, useWorktrees?: boolean) => {
if (mockRunningFeatures.has(featureId)) {
return { success: false, error: `Feature ${featureId} is already running` };
}
console.log(`[Mock] Running feature ${featureId} with useWorktrees: ${useWorktrees}`);
mockRunningFeatures.add(featureId);
simulateAutoModeLoop(projectPath, featureId);

View File

@@ -12,7 +12,8 @@ export type LogEntryType =
| "success"
| "info"
| "debug"
| "warning";
| "warning"
| "thinking";
export interface LogEntry {
id: string;
@@ -28,7 +29,27 @@ export interface LogEntry {
};
}
const generateId = () => Math.random().toString(36).substring(2, 9);
/**
* Generates a deterministic ID based on content and position
* This ensures the same log entry always gets the same ID,
* preserving expanded/collapsed state when new logs stream in
*
* Uses only the first 200 characters of content to ensure stability
* even when entries are merged (which appends content at the end)
*/
const generateDeterministicId = (content: string, lineIndex: number): string => {
// Use first 200 chars to ensure stability when entries are merged
const stableContent = content.slice(0, 200);
// Simple hash function for the content
let hash = 0;
const str = stableContent + '|' + lineIndex.toString();
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return 'log_' + Math.abs(hash).toString(36);
};
/**
* Detects the type of log entry based on content patterns
@@ -75,6 +96,18 @@ function detectEntryType(content: string): LogEntryType {
return "warning";
}
// Thinking/Preparation info
if (
trimmed.toLowerCase().includes("ultrathink") ||
trimmed.toLowerCase().includes("thinking level") ||
trimmed.toLowerCase().includes("estimated cost") ||
trimmed.toLowerCase().includes("estimated time") ||
trimmed.toLowerCase().includes("budget tokens") ||
trimmed.match(/thinking.*preparation/i)
) {
return "thinking";
}
// Debug info (JSON, stack traces, etc.)
if (
trimmed.startsWith("{") ||
@@ -130,6 +163,8 @@ function generateTitle(type: LogEntryType, content: string): string {
return "Success";
case "warning":
return "Warning";
case "thinking":
return "Thinking Level";
case "debug":
return "Debug Info";
case "prompt":
@@ -150,24 +185,32 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
const entries: LogEntry[] = [];
const lines = rawOutput.split("\n");
let currentEntry: LogEntry | null = null;
let currentEntry: Omit<LogEntry, 'id'> & { id?: string } | null = null;
let currentContent: string[] = [];
let entryStartLine = 0; // Track the starting line for deterministic ID generation
const finalizeEntry = () => {
if (currentEntry && currentContent.length > 0) {
currentEntry.content = currentContent.join("\n").trim();
if (currentEntry.content) {
entries.push(currentEntry);
// Generate deterministic ID based on content and position
const entryWithId: LogEntry = {
...currentEntry as Omit<LogEntry, 'id'>,
id: generateDeterministicId(currentEntry.content, entryStartLine),
};
entries.push(entryWithId);
}
}
currentContent = [];
};
let lineIndex = 0;
for (const line of lines) {
const trimmedLine = line.trim();
// Skip empty lines at the beginning
if (!trimmedLine && !currentEntry) {
lineIndex++;
continue;
}
@@ -180,15 +223,20 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
trimmedLine.startsWith("✅") ||
trimmedLine.startsWith("❌") ||
trimmedLine.startsWith("⚠️") ||
trimmedLine.startsWith("🧠") ||
trimmedLine.toLowerCase().includes("ultrathink preparation") ||
trimmedLine.toLowerCase().includes("thinking level") ||
(trimmedLine.startsWith("Input:") && currentEntry?.type === "tool_call");
if (isNewEntry) {
// Finalize previous entry
finalizeEntry();
// Start new entry
// Track starting line for deterministic ID
entryStartLine = lineIndex;
// Start new entry (ID will be generated when finalizing)
currentEntry = {
id: generateId(),
type: lineType,
title: generateTitle(lineType, trimmedLine),
content: "",
@@ -202,15 +250,18 @@ export function parseLogOutput(rawOutput: string): LogEntry[] {
// Continue current entry
currentContent.push(line);
} else {
// Track starting line for deterministic ID
entryStartLine = lineIndex;
// No current entry, create a default info entry
currentEntry = {
id: generateId(),
type: "info",
title: "Info",
content: "",
};
currentContent.push(line);
}
lineIndex++;
}
// Finalize last entry
@@ -230,6 +281,7 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
const merged: LogEntry[] = [];
let current: LogEntry | null = null;
let mergeIndex = 0;
for (const entry of entries) {
if (
@@ -237,13 +289,15 @@ function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] {
(current.type === "debug" || current.type === "info") &&
current.type === entry.type
) {
// Merge into current
// Merge into current - regenerate ID based on merged content
current.content += "\n\n" + entry.content;
current.id = generateDeterministicId(current.content, mergeIndex);
} else {
if (current) {
merged.push(current);
}
current = { ...entry };
mergeIndex = merged.length;
}
}
@@ -321,6 +375,14 @@ export function getLogTypeColors(type: LogEntryType): {
icon: "text-orange-400",
badge: "bg-orange-500/20 text-orange-300",
};
case "thinking":
return {
bg: "bg-indigo-500/10",
border: "border-l-indigo-500",
text: "text-indigo-300",
icon: "text-indigo-400",
badge: "bg-indigo-500/20 text-indigo-300",
};
case "debug":
return {
bg: "bg-primary/10",

View File

@@ -1,6 +1,45 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
import type { AgentModel } from "@/store/app-store"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/**
* Check if a model is a Codex/OpenAI model (doesn't support thinking)
*/
export function isCodexModel(model?: AgentModel | string): boolean {
if (!model) return false;
const codexModels: string[] = [
"gpt-5.1-codex-max",
"gpt-5.1-codex",
"gpt-5.1-codex-mini",
"gpt-5.1",
];
return codexModels.includes(model);
}
/**
* Determine if the current model supports extended thinking controls
*/
export function modelSupportsThinking(model?: AgentModel | string): boolean {
if (!model) return true;
return !isCodexModel(model);
}
/**
* Get display name for a model
*/
export function getModelDisplayName(model: AgentModel | string): string {
const displayNames: Record<string, string> = {
haiku: "Claude Haiku",
sonnet: "Claude Sonnet",
opus: "Claude Opus",
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
"gpt-5.1-codex": "GPT-5.1 Codex",
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
"gpt-5.1": "GPT-5.1",
};
return displayNames[model] || model;
}

View File

@@ -10,7 +10,8 @@ export type ViewMode =
| "settings"
| "tools"
| "interview"
| "context";
| "context"
| "profiles";
export type ThemeMode =
| "light"
@@ -32,6 +33,7 @@ export type KanbanCardDetailLevel = "minimal" | "standard" | "detailed";
export interface ApiKeys {
anthropic: string;
google: string;
openai: string;
}
export interface ImageAttachment {
@@ -75,6 +77,36 @@ export interface FeatureImagePath {
mimeType: string;
}
// Available models for feature execution
// Claude models
export type ClaudeModel = "opus" | "sonnet" | "haiku";
// OpenAI/Codex models
export type OpenAIModel =
| "gpt-5.1-codex-max"
| "gpt-5.1-codex"
| "gpt-5.1-codex-mini"
| "gpt-5.1";
// Combined model type
export type AgentModel = ClaudeModel | OpenAIModel;
// Model provider type
export type ModelProvider = "claude" | "codex";
// Thinking level (budget_tokens) options
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
// AI Provider Profile - user-defined presets for model configurations
export interface AIProfile {
id: string;
name: string;
description: string;
model: AgentModel;
thinkingLevel: ThinkingLevel;
provider: ModelProvider;
isBuiltIn: boolean; // Built-in profiles cannot be deleted
icon?: string; // Optional icon name from lucide
}
export interface Feature {
id: string;
category: string;
@@ -86,7 +118,30 @@ export interface Feature {
startedAt?: string; // ISO timestamp for when the card moved to in_progress
skipTests?: boolean; // When true, skip TDD approach and require manual verification
summary?: string; // Summary of what was done/modified by the agent
model?: AgentModel; // Model to use for this feature (defaults to opus)
thinkingLevel?: ThinkingLevel; // Thinking level for extended thinking (defaults to none)
error?: string; // Error message if the agent errored during processing
// Worktree info - set when a feature is being worked on in an isolated git worktree
worktreePath?: string; // Path to the worktree directory
branchName?: string; // Name of the feature branch
}
// File tree node for project analysis
export interface FileTreeNode {
name: string;
path: string;
isDirectory: boolean;
extension?: string;
children?: FileTreeNode[];
}
// Project analysis result
export interface ProjectAnalysis {
fileTree: FileTreeNode[];
totalFiles: number;
totalDirectories: number;
filesByExtension: Record<string, number>;
analyzedAt: string;
}
export interface AppState {
@@ -137,6 +192,19 @@ export interface AppState {
// Feature Default Settings
defaultSkipTests: boolean; // Default value for skip tests when creating new features
// Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
// AI Profiles
aiProfiles: AIProfile[];
// Profile Display Settings
showProfilesOnly: boolean; // When true, hide model tweaking options and show only profile selection
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
}
export interface AutoModeActivity {
@@ -226,6 +294,23 @@ export interface AppActions {
// Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void;
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
// Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void;
// AI Profile actions
addAIProfile: (profile: Omit<AIProfile, "id">) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
removeAIProfile: (id: string) => void;
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
// Project Analysis actions
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
setIsAnalyzing: (analyzing: boolean) => void;
clearAnalysis: () => void;
// Agent Session actions
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
getLastSelectedSession: (projectPath: string) => string | null;
@@ -234,6 +319,60 @@ export interface AppActions {
reset: () => void;
}
// Default built-in AI profiles
const DEFAULT_AI_PROFILES: AIProfile[] = [
{
id: "profile-heavy-task",
name: "Heavy Task",
description: "Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.",
model: "opus",
thinkingLevel: "ultrathink",
provider: "claude",
isBuiltIn: true,
icon: "Brain",
},
{
id: "profile-balanced",
name: "Balanced",
description: "Claude Sonnet with medium thinking for typical development tasks.",
model: "sonnet",
thinkingLevel: "medium",
provider: "claude",
isBuiltIn: true,
icon: "Scale",
},
{
id: "profile-quick-edit",
name: "Quick Edit",
description: "Claude Haiku for fast, simple edits and minor fixes.",
model: "haiku",
thinkingLevel: "none",
provider: "claude",
isBuiltIn: true,
icon: "Zap",
},
{
id: "profile-codex-power",
name: "Codex Power",
description: "GPT-5.1 Codex Max for deep coding tasks via OpenAI CLI.",
model: "gpt-5.1-codex-max",
thinkingLevel: "none",
provider: "codex",
isBuiltIn: true,
icon: "Cpu",
},
{
id: "profile-codex-fast",
name: "Codex Fast",
description: "GPT-5.1 Codex Mini for lightweight and quick edits.",
model: "gpt-5.1-codex-mini",
thinkingLevel: "none",
provider: "codex",
isBuiltIn: true,
icon: "Rocket",
},
];
const initialState: AppState = {
projects: [],
currentProject: null,
@@ -250,6 +389,7 @@ const initialState: AppState = {
apiKeys: {
anthropic: "",
google: "",
openai: "",
},
chatSessions: [],
currentChatSession: null,
@@ -259,6 +399,11 @@ const initialState: AppState = {
maxConcurrency: 3, // Default to 3 concurrent agents
kanbanCardDetailLevel: "standard", // Default to standard detail level
defaultSkipTests: false, // Default to TDD mode (tests enabled)
useWorktrees: false, // Default to disabled (worktree feature is experimental)
showProfilesOnly: false, // Default to showing all options (not profiles only)
aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null,
isAnalyzing: false,
};
export const useAppStore = create<AppState & AppActions>()(
@@ -722,6 +867,48 @@ export const useAppStore = create<AppState & AppActions>()(
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
// Profile Display Settings actions
setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }),
// AI Profile actions
addAIProfile: (profile) => {
const id = `profile-${Date.now()}-${Math.random()
.toString(36)
.substr(2, 9)}`;
set({ aiProfiles: [...get().aiProfiles, { ...profile, id }] });
},
updateAIProfile: (id, updates) => {
set({
aiProfiles: get().aiProfiles.map((p) =>
p.id === id ? { ...p, ...updates } : p
),
});
},
removeAIProfile: (id) => {
// Only allow removing non-built-in profiles
const profile = get().aiProfiles.find((p) => p.id === id);
if (profile && !profile.isBuiltIn) {
set({ aiProfiles: get().aiProfiles.filter((p) => p.id !== id) });
}
},
reorderAIProfiles: (oldIndex, newIndex) => {
const profiles = [...get().aiProfiles];
const [movedProfile] = profiles.splice(oldIndex, 1);
profiles.splice(newIndex, 0, movedProfile);
set({ aiProfiles: profiles });
},
// Project Analysis actions
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
clearAnalysis: () => set({ projectAnalysis: null }),
// Agent Session actions
setLastSelectedSession: (projectPath, sessionId) => {
const current = get().lastSelectedSessionByProject;
@@ -742,7 +929,6 @@ export const useAppStore = create<AppState & AppActions>()(
getLastSelectedSession: (projectPath) => {
return get().lastSelectedSessionByProject[projectPath] || null;
},
// Reset
reset: () => set(initialState),
}),
@@ -763,6 +949,9 @@ export const useAppStore = create<AppState & AppActions>()(
maxConcurrency: state.maxConcurrency,
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
defaultSkipTests: state.defaultSkipTests,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
aiProfiles: state.aiProfiles,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
}),
}

View File

@@ -202,6 +202,14 @@ export type AutoModeEvent =
projectId?: string;
phase: "planning" | "action" | "verification";
message: string;
}
| {
type: "auto_mode_ultrathink_preparation";
featureId: string;
warnings: string[];
recommendations: string[];
estimatedCost?: number;
estimatedTime?: string;
};
export type SpecRegenerationEvent =
@@ -279,7 +287,7 @@ export interface AutoModeAPI {
error?: string;
}>;
runFeature: (projectPath: string, featureId: string) => Promise<{
runFeature: (projectPath: string, featureId: string, useWorktrees?: boolean) => Promise<{
success: boolean;
passes?: boolean;
error?: string;
@@ -370,6 +378,10 @@ export interface ElectronAPI {
};
error?: string;
}>;
deleteFile: (filePath: string) => Promise<{
success: boolean;
error?: string;
}>;
// App APIs
getPath: (name: string) => Promise<string>;
@@ -393,10 +405,191 @@ export interface ElectronAPI {
// Auto Mode APIs
autoMode: AutoModeAPI;
// Claude CLI Detection API
checkClaudeCli: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}>;
// Codex CLI Detection API
checkCodexCli: () => Promise<{
success: boolean;
status?: string;
method?: string;
version?: string;
path?: string;
hasApiKey?: boolean;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
error?: string;
}>;
// Model Management APIs
model: {
// Get all available models from all providers
getAvailable: () => Promise<{
success: boolean;
models?: ModelDefinition[];
error?: string;
}>;
// Check all provider installation status
checkProviders: () => Promise<{
success: boolean;
providers?: Record<string, ProviderStatus>;
error?: string;
}>;
};
// OpenAI API
testOpenAIConnection: (apiKey?: string) => Promise<{
success: boolean;
message?: string;
error?: string;
}>;
// Worktree Management APIs
worktree: WorktreeAPI;
// Git Operations APIs (for non-worktree operations)
git: GitAPI;
// Spec Regeneration APIs
specRegeneration: SpecRegenerationAPI;
}
export interface WorktreeInfo {
worktreePath: string;
branchName: string;
head?: string;
baseBranch?: string;
}
export interface WorktreeStatus {
success: boolean;
modifiedFiles?: number;
files?: string[];
diffStat?: string;
recentCommits?: string[];
error?: string;
}
export interface FileStatus {
status: string;
path: string;
statusText: string;
}
export interface FileDiffsResult {
success: boolean;
diff?: string;
files?: FileStatus[];
hasChanges?: boolean;
error?: string;
}
export interface FileDiffResult {
success: boolean;
diff?: string;
filePath?: string;
error?: string;
}
export interface WorktreeAPI {
// Revert feature changes by removing the worktree
revertFeature: (projectPath: string, featureId: string) => Promise<{
success: boolean;
removedPath?: string;
error?: string;
}>;
// Merge feature worktree changes back to main branch
mergeFeature: (projectPath: string, featureId: string, options?: {
squash?: boolean;
commitMessage?: string;
squashMessage?: string;
}) => Promise<{
success: boolean;
mergedBranch?: string;
error?: string;
}>;
// Get worktree info for a feature
getInfo: (projectPath: string, featureId: string) => Promise<{
success: boolean;
worktreePath?: string;
branchName?: string;
head?: string;
error?: string;
}>;
// Get worktree status (changed files, commits)
getStatus: (projectPath: string, featureId: string) => Promise<WorktreeStatus>;
// List all feature worktrees
list: (projectPath: string) => Promise<{
success: boolean;
worktrees?: WorktreeInfo[];
error?: string;
}>;
// Get file diffs for a feature worktree
getDiffs: (projectPath: string, featureId: string) => Promise<FileDiffsResult>;
// Get diff for a specific file in a worktree
getFileDiff: (projectPath: string, featureId: string, filePath: string) => Promise<FileDiffResult>;
}
export interface GitAPI {
// Get diffs for the main project (not a worktree)
getDiffs: (projectPath: string) => Promise<FileDiffsResult>;
// Get diff for a specific file in the main project
getFileDiff: (projectPath: string, filePath: string) => Promise<FileDiffResult>;
}
// Model definition type
export interface ModelDefinition {
id: string;
name: string;
modelString: string;
provider: "claude" | "codex";
description?: string;
tier?: "basic" | "standard" | "premium";
default?: boolean;
}
// Provider status type
export interface ProviderStatus {
status: "installed" | "not_installed" | "api_key_only";
method?: string;
version?: string;
path?: string;
recommendation?: string;
installCommands?: {
macos?: string;
windows?: string;
linux?: string;
npm?: string;
};
}
declare global {
interface Window {
electronAPI: ElectronAPI;