41 Commits

Author SHA1 Message Date
gsxdsm
aa345a50ac feat: Add PR review comments and resolution endpoints, improve prompt handling 2026-02-20 16:08:15 -08:00
gsxdsm
0e020f7e4a Feature: File Editor (#789)
* feat: Add file management feature

* feat: Add auto-save functionality to file editor

* fix: Replace HardDriveDownload icon with Save icon for consistency

* fix: Prevent recursive copy/move and improve shell injection prevention

* refactor: Extract editor settings form into separate component
2026-02-20 16:06:44 -08:00
gsxdsm
0a5540c9a2 Fix concurrency limits and remote branch fetching issues (#788)
* Changes from fix/bug-fixes

* feat: Refactor worktree iteration and improve error logging across services

* feat: Extract URL/port patterns to module level and fix abort condition

* fix: Improve IPv6 loopback handling, select component layout, and terminal UI

* feat: Add thinking level defaults and adjust list row padding

* Update apps/ui/src/store/app-store.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit

* feat: Add tracked remote detection to pull dialog flow

* feat: Add merge state tracking to git operations

* feat: Improve merge detection and add post-merge action preferences

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Pass merge detection info to stash reapplication and handle merge state consistently

* fix: Call onPulled callback in merge handlers and add validation checks

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-20 13:48:22 -08:00
gsxdsm
7df2182818 Improve pull request flow, add branch selection for worktree creation, fix auto-mode concurrency count (#787)
* Changes from fix/fetch-before-pull-fetch

* feat: Improve pull request flow, add branch selection for worktree creation, fix for automode concurrency count

* feat: Add validation for remote names and improve error handling

* Address PR comments and mobile layout fixes

* ```
refactor: Extract PR target resolution logic into dedicated service
```

* feat: Add app shell UI and improve service imports. Address PR comments

* fix: Improve security validation and cache handling in git operations

* feat: Add GET /list endpoint and improve parameter handling

* chore: Improve validation, accessibility, and error handling across apps

* chore: Format vite server port configuration

* fix: Add error handling for gh pr list command and improve offline fallbacks

* fix: Preserve existing PR creation time and improve remote handling
2026-02-19 21:55:12 -08:00
DhanushSantosh
ee52333636 chore: refresh lockfile after dependency sync 2026-02-20 00:08:13 +05:30
gsxdsm
47bd7a76cf Merge pull request #782 from gsxdsm/feat/mobile-improvements
Feature: Comprehensive mobile improvements and bug fixes
2026-02-18 23:51:32 -08:00
gsxdsm
ae10dea2bf feat: Add includeUntracked option and improve error handling for stash operations 2026-02-18 23:32:24 -08:00
gsxdsm
be4153c374 fix: Improve error handling and state management in auto-mode and utilities 2026-02-18 23:12:11 -08:00
gsxdsm
a144a63c51 fix: Resolve git operation error handling and conflict detection issues 2026-02-18 23:03:39 -08:00
gsxdsm
205f662022 fix: Improve error handling and validation across multiple services 2026-02-18 22:11:31 -08:00
gsxdsm
53d07fefb8 feat: Fix new branch issues and address code review comments 2026-02-18 21:36:00 -08:00
gsxdsm
2d907938cc feat: Add TypeScript type annotation and fix session_id default value 2026-02-18 20:55:58 -08:00
gsxdsm
15ca1eb6d3 feat: Add process abort control and improve auth detection 2026-02-18 20:48:37 -08:00
gsxdsm
4ee160fae4 fix: Address review comments 2026-02-18 19:52:25 -08:00
gsxdsm
4ba0026aa1 feat: Add conflict resolution event types 2026-02-18 19:31:09 -08:00
gsxdsm
983eb21faa feat: Address review comments, add stage/unstage functionality, conflict resolution improvements, support for Sonnet 4.6 2026-02-18 18:58:33 -08:00
gsxdsm
df9a6314da refactor: Enhance session management and error handling in AgentService and related components
- Improved session handling by implementing ensureSession to load sessions from disk if not in memory, reducing "session not found" errors.
- Enhanced error messages for non-existent sessions, providing clearer diagnostics.
- Updated CodexProvider and OpencodeProvider to improve error handling and messaging.
- Refactored various routes to use async/await for better readability and error handling.
- Added event emission for merge and stash operations in the MergeService and StashService.
- Cleaned up error messages in AgentExecutor to remove redundant prefixes and ANSI codes for better clarity.
2026-02-18 17:30:12 -08:00
gsxdsm
6903d3c508 fix: Standardize event name and import path 2026-02-18 11:21:43 -08:00
gsxdsm
5c441f2313 feat: Add GPT-5 model variants and improve Codex execution logic. Addressed code review comments 2026-02-18 11:15:38 -08:00
gsxdsm
d30296d559 feat: Add git log parsing and rebase endpoint with input validation 2026-02-18 00:37:41 -08:00
gsxdsm
e6e04d57bc Update apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 23:37:49 -08:00
gsxdsm
829c16181b Update apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 23:37:10 -08:00
gsxdsm
13261b7e8c Update apps/ui/src/components/dialogs/project-file-selector-dialog.tsx
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 23:36:43 -08:00
gsxdsm
854ba6ec74 fix: Add symlink validation to prevent path traversal attacks 2026-02-17 23:22:08 -08:00
gsxdsm
bddf1a4bf8 fix: Handle staged-new files correctly in discard changes 2026-02-17 23:19:38 -08:00
gsxdsm
887e2ea76b fix: Correct parsing of git output blocks and improve stash UI accessibility 2026-02-17 23:15:21 -08:00
gsxdsm
dd4c738e91 fix: Address code review comments 2026-02-17 23:15:21 -08:00
gsxdsm
43c19c70ca Update apps/server/src/routes/worktree/routes/discard-changes.ts
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 22:38:55 -08:00
gsxdsm
cb99c4b4e8 feat: Replace Select with Popover+Command for branch selection UI 2026-02-17 22:08:22 -08:00
gsxdsm
9af63bc1ef refactor: Improve all git operations, add stash support, add improved pull request flow, add worktree file copy options, address code review comments, add cherry pick options 2026-02-17 22:02:58 -08:00
gsxdsm
f4e87d4c25 Update apps/ui/src/styles/global.css
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-17 19:39:32 -08:00
gsxdsm
c7f515adde feat: Add auto-fix for SSH URLs in lockfile before linting 2026-02-17 18:52:15 -08:00
gsxdsm
1df778a9db chore: Add PageTransitionEvent and APP_BUILD_HASH to eslint globals 2026-02-17 17:37:35 -08:00
gsxdsm
cb44f8a717 Comprehensive set of mobile and all improvements phase 1 2026-02-17 17:33:11 -08:00
gsxdsm
7fcf3c1e1f feat: Mobile improvements and Add selective file staging and improve branch switching 2026-02-17 15:20:28 -08:00
gsxdsm
de021f96bf fix: Remove unused vars and improve type safety. Improve task recovery 2026-02-17 13:18:40 -08:00
gsxdsm
8bb10632b1 Merge remote-tracking branch 'upstream/v0.15.0rc' into feat/add-zai-usage-tracking
# Conflicts:
#	apps/ui/src/components/usage-popover.tsx
#	apps/ui/src/components/views/board-view/mobile-usage-bar.tsx
2026-02-17 11:19:06 -08:00
gsxdsm
bea26a6b61 style: Fix inconsistent indentation in components and imports 2026-02-15 22:50:01 -08:00
eclipxe
ac2e8cfa88 Feat: Add z.ai usage tracking 2026-02-15 20:57:09 -08:00
eclipxe
7d5bc722fa Feat: Show Gemini Usage in usage dropdown and mobile sidebar 2026-02-15 20:56:53 -08:00
eclipxe
7765a12868 Feat: Add z.ai usage tracking 2026-02-15 20:55:37 -08:00
367 changed files with 42506 additions and 3889 deletions

View File

@@ -25,17 +25,24 @@ runs:
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Check for SSH URLs in lockfile
if: inputs.check-lockfile == 'true'
shell: bash
run: npm run lint:lockfile
- name: Configure Git for HTTPS
shell: bash
# Convert SSH URLs to HTTPS for git dependencies (e.g., @electron/node-gyp)
# This is needed because SSH authentication isn't available in CI
run: git config --global url."https://github.com/".insteadOf "git@github.com:"
- name: Auto-fix SSH URLs in lockfile
if: inputs.check-lockfile == 'true'
shell: bash
# Auto-fix any git+ssh:// URLs in package-lock.json before linting
# This handles cases where npm reintroduces SSH URLs for git dependencies
run: node scripts/fix-lockfile-urls.mjs
- name: Check for SSH URLs in lockfile
if: inputs.check-lockfile == 'true'
shell: bash
run: npm run lint:lockfile
- name: Install dependencies
shell: bash
# Use npm install instead of npm ci to correctly resolve platform-specific

View File

@@ -133,6 +133,7 @@ jobs:
env:
CI: true
VITE_SERVER_URL: http://localhost:3008
SERVER_URL: http://localhost:3008
VITE_SKIP_SETUP: 'true'
# Keep UI-side login/defaults consistent
AUTOMAKER_API_KEY: test-api-key-for-e2e-tests

View File

@@ -38,6 +38,18 @@ else
export PATH="$PATH:/usr/local/bin:/opt/homebrew/bin:/usr/bin"
fi
# Auto-fix git+ssh:// URLs in package-lock.json if it's being committed
# This prevents CI failures from SSH URLs that npm introduces for git dependencies
if git diff --cached --name-only | grep -q "^package-lock.json$"; then
if command -v node >/dev/null 2>&1; then
if grep -q "git+ssh://" package-lock.json 2>/dev/null; then
echo "Fixing git+ssh:// URLs in package-lock.json..."
node scripts/fix-lockfile-urls.mjs
git add package-lock.json
fi
fi
fi
# Run lint-staged - works with or without nvm
# Prefer npx, fallback to npm exec, both work with system-installed Node.js
if command -v npx >/dev/null 2>&1; then

2
OPENCODE_CONFIG_CONTENT Normal file
View File

@@ -0,0 +1,2 @@
{
"$schema": "https://opencode.ai/config.json",}

View File

@@ -66,6 +66,10 @@ import { createCodexRoutes } from './routes/codex/index.js';
import { CodexUsageService } from './services/codex-usage-service.js';
import { CodexAppServerService } from './services/codex-app-server-service.js';
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
import { createZaiRoutes } from './routes/zai/index.js';
import { ZaiUsageService } from './services/zai-usage-service.js';
import { createGeminiRoutes } from './routes/gemini/index.js';
import { GeminiUsageService } from './services/gemini-usage-service.js';
import { createGitHubRoutes } from './routes/github/index.js';
import { createContextRoutes } from './routes/context/index.js';
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
@@ -300,7 +304,7 @@ app.use(
callback(null, origin);
return;
}
} catch (err) {
} catch {
// Ignore URL parsing errors
}
@@ -328,6 +332,8 @@ const claudeUsageService = new ClaudeUsageService();
const codexAppServerService = new CodexAppServerService();
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
const codexUsageService = new CodexUsageService(codexAppServerService);
const zaiUsageService = new ZaiUsageService();
const geminiUsageService = new GeminiUsageService();
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
@@ -372,7 +378,7 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
let globalSettings: Awaited<ReturnType<typeof settingsService.getGlobalSettings>> | null = null;
try {
globalSettings = await settingsService.getGlobalSettings();
} catch (err) {
} catch {
logger.warn('Failed to load global settings, using defaults');
}
@@ -390,7 +396,7 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
const enableRequestLog = globalSettings.enableRequestLogging ?? true;
setRequestLoggingEnabled(enableRequestLog);
logger.info(`HTTP request logging: ${enableRequestLog ? 'enabled' : 'disabled'}`);
} catch (err) {
} catch {
logger.warn('Failed to apply logging settings, using defaults');
}
}
@@ -417,6 +423,22 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur
} else {
logger.info('[STARTUP] Feature state reconciliation complete - no stale states found');
}
// Resume interrupted features in the background after reconciliation.
// This uses the saved execution state to identify features that were running
// before the restart (their statuses have been reset to ready/backlog by
// reconciliation above). Running in background so it doesn't block startup.
if (totalReconciled > 0) {
for (const project of globalSettings.projects) {
autoModeService.resumeInterruptedFeatures(project.path).catch((err) => {
logger.warn(
`[STARTUP] Failed to resume interrupted features for ${project.path}:`,
err
);
});
}
logger.info('[STARTUP] Initiated background resume of interrupted features');
}
}
} catch (err) {
logger.warn('[STARTUP] Failed to reconcile feature states:', err);
@@ -473,6 +495,8 @@ app.use('/api/terminal', createTerminalRoutes());
app.use('/api/settings', createSettingsRoutes(settingsService));
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService));
app.use('/api/gemini', createGeminiRoutes(geminiUsageService, events));
app.use('/api/github', createGitHubRoutes(events, settingsService));
app.use('/api/context', createContextRoutes(settingsService));
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
@@ -575,7 +599,7 @@ wss.on('connection', (ws: WebSocket) => {
logger.info('Sending event to client:', {
type,
messageLength: message.length,
sessionId: (payload as any)?.sessionId,
sessionId: (payload as Record<string, unknown>)?.sessionId,
});
ws.send(message);
} else {
@@ -641,8 +665,15 @@ terminalWss.on('connection', (ws: WebSocket, req: import('http').IncomingMessage
// Check if session exists
const session = terminalService.getSession(sessionId);
if (!session) {
logger.info(`Session ${sessionId} not found`);
ws.close(4004, 'Session not found');
logger.warn(
`Terminal session ${sessionId} not found. ` +
`The session may have exited, been deleted, or was never created. ` +
`Active terminal sessions: ${terminalService.getSessionCount()}`
);
ws.close(
4004,
'Session not found. The terminal session may have expired or been closed. Please create a new terminal.'
);
return;
}

View File

@@ -8,9 +8,6 @@ import { spawn, execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CliDetection');
export interface CliInfo {
name: string;
@@ -86,7 +83,7 @@ export async function detectCli(
options: CliDetectionOptions = {}
): Promise<CliDetectionResult> {
const config = CLI_CONFIGS[provider];
const { timeout = 5000, includeWsl = false, wslDistribution } = options;
const { timeout = 5000 } = options;
const issues: string[] = [];
const cliInfo: CliInfo = {

View File

@@ -40,7 +40,7 @@ export interface ErrorClassification {
suggestedAction?: string;
retryable: boolean;
provider?: string;
context?: Record<string, any>;
context?: Record<string, unknown>;
}
export interface ErrorPattern {
@@ -180,7 +180,7 @@ const ERROR_PATTERNS: ErrorPattern[] = [
export function classifyError(
error: unknown,
provider?: string,
context?: Record<string, any>
context?: Record<string, unknown>
): ErrorClassification {
const errorText = getErrorText(error);
@@ -281,18 +281,19 @@ function getErrorText(error: unknown): string {
if (typeof error === 'object' && error !== null) {
// Handle structured error objects
const errorObj = error as any;
const errorObj = error as Record<string, unknown>;
if (errorObj.message) {
if (typeof errorObj.message === 'string') {
return errorObj.message;
}
if (errorObj.error?.message) {
return errorObj.error.message;
const nestedError = errorObj.error;
if (typeof nestedError === 'object' && nestedError !== null && 'message' in nestedError) {
return String((nestedError as Record<string, unknown>).message);
}
if (errorObj.error) {
return typeof errorObj.error === 'string' ? errorObj.error : JSON.stringify(errorObj.error);
if (nestedError) {
return typeof nestedError === 'string' ? nestedError : JSON.stringify(nestedError);
}
return JSON.stringify(error);
@@ -307,7 +308,7 @@ function getErrorText(error: unknown): string {
export function createErrorResponse(
error: unknown,
provider?: string,
context?: Record<string, any>
context?: Record<string, unknown>
): {
success: false;
error: string;
@@ -335,7 +336,7 @@ export function logError(
error: unknown,
provider?: string,
operation?: string,
additionalContext?: Record<string, any>
additionalContext?: Record<string, unknown>
): void {
const classification = classifyError(error, provider, {
operation,

View File

@@ -0,0 +1,62 @@
export interface CommitFields {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
}
export function parseGitLogOutput(output: string): CommitFields[] {
const commits: CommitFields[] = [];
// Split by NUL character to separate commits
const commitBlocks = output.split('\0').filter((block) => block.trim());
for (const block of commitBlocks) {
const allLines = block.split('\n');
// Skip leading empty lines that may appear at block boundaries
let startIndex = 0;
while (startIndex < allLines.length && allLines[startIndex].trim() === '') {
startIndex++;
}
const fields = allLines.slice(startIndex);
// Validate we have all expected fields (at least hash, shortHash, author, authorEmail, date, subject)
if (fields.length < 6) {
continue; // Skip malformed blocks
}
const commit: CommitFields = {
hash: fields[0].trim(),
shortHash: fields[1].trim(),
author: fields[2].trim(),
authorEmail: fields[3].trim(),
date: fields[4].trim(),
subject: fields[5].trim(),
body: fields.slice(6).join('\n').trim(),
};
commits.push(commit);
}
return commits;
}
/**
* Creates a commit object from parsed fields, matching the expected API response format
*/
export function createCommitFromFields(fields: CommitFields, files?: string[]) {
return {
hash: fields.hash,
shortHash: fields.shortHash,
author: fields.author,
authorEmail: fields.authorEmail,
date: fields.date,
subject: fields.subject,
body: fields.body,
files: files || [],
};
}

208
apps/server/src/lib/git.ts Normal file
View File

@@ -0,0 +1,208 @@
/**
* Shared git command execution utilities.
*
* This module provides the canonical `execGitCommand` helper and common
* git utilities used across services and routes. All consumers should
* import from here rather than defining their own copy.
*/
import fs from 'fs/promises';
import path from 'path';
import { spawnProcess } from '@automaker/platform';
import { createLogger } from '@automaker/utils';
const logger = createLogger('GitLib');
// ============================================================================
// Secure Command Execution
// ============================================================================
/**
* Execute git command with array arguments to prevent command injection.
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
*
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
* @param cwd - Working directory to execute the command in
* @param env - Optional additional environment variables to pass to the git process.
* These are merged on top of the current process environment. Pass
* `{ LC_ALL: 'C' }` to force git to emit English output regardless of the
* system locale so that text-based output parsing remains reliable.
* @param abortController - Optional AbortController to cancel the git process.
* When the controller is aborted the underlying process is sent SIGTERM and
* the returned promise rejects with an Error whose message is 'Process aborted'.
* @returns Promise resolving to stdout output
* @throws Error with stderr/stdout message if command fails. The thrown error
* also has `stdout` and `stderr` string properties for structured access.
*
* @example
* ```typescript
* // Safe: no injection possible
* await execGitCommand(['branch', '-D', branchName], projectPath);
*
* // Force English output for reliable text parsing:
* await execGitCommand(['rebase', '--', 'main'], worktreePath, { LC_ALL: 'C' });
*
* // With a process-level timeout:
* const controller = new AbortController();
* const timerId = setTimeout(() => controller.abort(), 30_000);
* try {
* await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
* } finally {
* clearTimeout(timerId);
* }
*
* // Instead of unsafe:
* // await execAsync(`git branch -D ${branchName}`, { cwd });
* ```
*/
export async function execGitCommand(
args: string[],
cwd: string,
env?: Record<string, string>,
abortController?: AbortController
): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
...(env !== undefined ? { env } : {}),
...(abortController !== undefined ? { abortController } : {}),
});
// spawnProcess returns { stdout, stderr, exitCode }
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage =
result.stderr || result.stdout || `Git command failed with code ${result.exitCode}`;
throw Object.assign(new Error(errorMessage), {
stdout: result.stdout,
stderr: result.stderr,
});
}
}
// ============================================================================
// Common Git Utilities
// ============================================================================
/**
* Get the current branch name for the given worktree.
*
* This is the canonical implementation shared across services. Services
* should import this rather than duplicating the logic locally.
*
* @param worktreePath - Path to the git worktree
* @returns The current branch name (trimmed)
*/
export async function getCurrentBranch(worktreePath: string): Promise<string> {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
return branchOutput.trim();
}
// ============================================================================
// Index Lock Recovery
// ============================================================================
/**
* Check whether an error message indicates a stale git index lock file.
*
* Git operations that write to the index (e.g. `git stash push`) will fail
* with "could not write index" or "Unable to create ... .lock" when a
* `.git/index.lock` file exists from a previously interrupted operation.
*
* @param errorMessage - The error string from a failed git command
* @returns true if the error looks like a stale index lock issue
*/
export function isIndexLockError(errorMessage: string): boolean {
const lower = errorMessage.toLowerCase();
return (
lower.includes('could not write index') ||
(lower.includes('unable to create') && lower.includes('index.lock')) ||
lower.includes('index.lock')
);
}
/**
* Attempt to remove a stale `.git/index.lock` file for the given worktree.
*
* Uses `git rev-parse --git-dir` to locate the correct `.git` directory,
* which works for both regular repositories and linked worktrees.
*
* @param worktreePath - Path to the git worktree (or main repo)
* @returns true if a lock file was found and removed, false otherwise
*/
export async function removeStaleIndexLock(worktreePath: string): Promise<boolean> {
try {
// Resolve the .git directory (handles worktrees correctly)
const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath);
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
const lockFilePath = path.join(gitDir, 'index.lock');
// Check if the lock file exists
try {
await fs.access(lockFilePath);
} catch {
// Lock file does not exist — nothing to remove
return false;
}
// Remove the stale lock file
await fs.unlink(lockFilePath);
logger.info('Removed stale index.lock file', { worktreePath, lockFilePath });
return true;
} catch (err) {
logger.warn('Failed to remove stale index.lock file', {
worktreePath,
error: err instanceof Error ? err.message : String(err),
});
return false;
}
}
/**
* Execute a git command with automatic retry when a stale index.lock is detected.
*
* If the command fails with an error indicating a locked index file, this
* helper will attempt to remove the stale `.git/index.lock` and retry the
* command exactly once.
*
* This is particularly useful for `git stash push` which writes to the
* index and commonly fails when a previous git operation was interrupted.
*
* @param args - Array of git command arguments
* @param cwd - Working directory to execute the command in
* @param env - Optional additional environment variables
* @returns Promise resolving to stdout output
* @throws The original error if retry also fails, or a non-lock error
*/
export async function execGitCommandWithLockRetry(
args: string[],
cwd: string,
env?: Record<string, string>
): Promise<string> {
try {
return await execGitCommand(args, cwd, env);
} catch (error: unknown) {
const err = error as { message?: string; stderr?: string };
const errorMessage = err.stderr || err.message || '';
if (!isIndexLockError(errorMessage)) {
throw error;
}
logger.info('Git command failed due to index lock, attempting cleanup and retry', {
cwd,
args: args.join(' '),
});
const removed = await removeStaleIndexLock(cwd);
if (!removed) {
// Could not remove the lock file — re-throw the original error
throw error;
}
// Retry the command once after removing the lock file
return await execGitCommand(args, cwd, env);
}
}

View File

@@ -12,11 +12,18 @@ export interface PermissionCheckResult {
reason?: string;
}
/** Minimal shape of a Cursor tool call used for permission checking */
interface CursorToolCall {
shellToolCall?: { args?: { command: string } };
readToolCall?: { args?: { path: string } };
writeToolCall?: { args?: { path: string } };
}
/**
* Check if a tool call is allowed based on permissions
*/
export function checkToolCallPermission(
toolCall: any,
toolCall: CursorToolCall,
permissions: CursorCliConfigFile | null
): PermissionCheckResult {
if (!permissions || !permissions.permissions) {
@@ -152,7 +159,11 @@ function matchesRule(toolName: string, rule: string): boolean {
/**
* Log permission violations
*/
export function logPermissionViolation(toolCall: any, reason: string, sessionId?: string): void {
export function logPermissionViolation(
toolCall: CursorToolCall,
reason: string,
sessionId?: string
): void {
const sessionIdStr = sessionId ? ` [${sessionId}]` : '';
if (toolCall.shellToolCall?.args?.command) {

View File

@@ -78,7 +78,7 @@ export async function readWorktreeMetadata(
const metadataPath = getWorktreeMetadataPath(projectPath, branch);
const content = (await secureFs.readFile(metadataPath, 'utf-8')) as string;
return JSON.parse(content) as WorktreeMetadata;
} catch (error) {
} catch (_error) {
// File doesn't exist or can't be read
return null;
}

View File

@@ -5,11 +5,10 @@
* with the provider architecture.
*/
import { query, type Options } from '@anthropic-ai/claude-agent-sdk';
import { query, type Options, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk';
import { BaseProvider } from './base-provider.js';
import { classifyError, getUserFriendlyErrorMessage, createLogger } from '@automaker/utils';
const logger = createLogger('ClaudeProvider');
import { getClaudeAuthIndicators } from '@automaker/platform';
import {
getThinkingTokenBudget,
validateBareModelId,
@@ -17,6 +16,14 @@ import {
type ClaudeCompatibleProvider,
type Credentials,
} from '@automaker/types';
import type {
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from './types.js';
const logger = createLogger('ClaudeProvider');
/**
* ProviderConfig - Union type for provider configuration
@@ -25,37 +32,6 @@ import {
* Both share the same connection settings structure.
*/
type ProviderConfig = ClaudeApiProfile | ClaudeCompatibleProvider;
import type {
ExecuteOptions,
ProviderMessage,
InstallationStatus,
ModelDefinition,
} from './types.js';
// Explicit allowlist of environment variables to pass to the SDK.
// Only these vars are passed - nothing else from process.env leaks through.
const ALLOWED_ENV_VARS = [
// Authentication
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
// Endpoint configuration
'ANTHROPIC_BASE_URL',
'API_TIMEOUT_MS',
// Model mappings
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL',
// Traffic control
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
// System vars (always from process.env)
'PATH',
'HOME',
'SHELL',
'TERM',
'USER',
'LANG',
'LC_ALL',
];
// System vars are always passed from process.env regardless of profile
const SYSTEM_ENV_VARS = ['PATH', 'HOME', 'SHELL', 'TERM', 'USER', 'LANG', 'LC_ALL'];
@@ -258,14 +234,14 @@ export class ClaudeProvider extends BaseProvider {
};
// Build prompt payload
let promptPayload: string | AsyncIterable<any>;
let promptPayload: string | AsyncIterable<SDKUserMessage>;
if (Array.isArray(prompt)) {
// Multi-part prompt (with images)
promptPayload = (async function* () {
const multiPartPrompt = {
const multiPartPrompt: SDKUserMessage = {
type: 'user' as const,
session_id: '',
session_id: sdkSessionId || '',
message: {
role: 'user' as const,
content: prompt,
@@ -317,12 +293,16 @@ export class ClaudeProvider extends BaseProvider {
? `${userMessage}\n\nTip: If you're running multiple features in auto-mode, consider reducing concurrency (maxConcurrency setting) to avoid hitting rate limits.`
: userMessage;
const enhancedError = new Error(message);
(enhancedError as any).originalError = error;
(enhancedError as any).type = errorInfo.type;
const enhancedError = new Error(message) as Error & {
originalError: unknown;
type: string;
retryAfter?: number;
};
enhancedError.originalError = error;
enhancedError.type = errorInfo.type;
if (errorInfo.isRateLimit) {
(enhancedError as any).retryAfter = errorInfo.retryAfter;
enhancedError.retryAfter = errorInfo.retryAfter;
}
throw enhancedError;
@@ -334,13 +314,37 @@ export class ClaudeProvider extends BaseProvider {
*/
async detectInstallation(): Promise<InstallationStatus> {
// Claude SDK is always available since it's a dependency
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
// Check all four supported auth methods, mirroring the logic in buildEnv():
// 1. ANTHROPIC_API_KEY environment variable
// 2. ANTHROPIC_AUTH_TOKEN environment variable
// 3. credentials?.apiKeys?.anthropic (credentials file, checked via platform indicators)
// 4. Claude Max CLI OAuth (SDK handles this automatically; detected via getClaudeAuthIndicators)
const hasEnvApiKey = !!process.env.ANTHROPIC_API_KEY;
const hasEnvAuthToken = !!process.env.ANTHROPIC_AUTH_TOKEN;
// Check credentials file and CLI OAuth indicators (same sources used by buildEnv)
let hasCredentialsApiKey = false;
let hasCliOAuth = false;
try {
const indicators = await getClaudeAuthIndicators();
hasCredentialsApiKey = !!indicators.credentials?.hasApiKey;
hasCliOAuth = !!(
indicators.credentials?.hasOAuthToken ||
indicators.hasStatsCacheWithActivity ||
(indicators.hasSettingsFile && indicators.hasProjectsSessions)
);
} catch {
// If we can't check indicators, fall back to env vars only
}
const hasApiKey = hasEnvApiKey || hasCredentialsApiKey;
const authenticated = hasEnvApiKey || hasEnvAuthToken || hasCredentialsApiKey || hasCliOAuth;
const status: InstallationStatus = {
installed: true,
method: 'sdk',
hasApiKey,
authenticated: hasApiKey,
authenticated,
};
return status;
@@ -364,6 +368,18 @@ export class ClaudeProvider extends BaseProvider {
tier: 'premium' as const,
default: true,
},
{
id: 'claude-sonnet-4-6',
name: 'Claude Sonnet 4.6',
modelString: 'claude-sonnet-4-6',
provider: 'anthropic',
description: 'Balanced performance and cost with enhanced reasoning',
contextWindow: 200000,
maxOutputTokens: 64000,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
},
{
id: 'claude-sonnet-4-20250514',
name: 'Claude Sonnet 4',

View File

@@ -32,6 +32,19 @@ export const CODEX_MODELS: ModelDefinition[] = [
default: true,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt53CodexSpark,
name: 'GPT-5.3-Codex-Spark',
modelString: CODEX_MODEL_MAP.gpt53CodexSpark,
provider: 'openai',
description: 'Near-instant real-time coding model, 1000+ tokens/sec.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'premium' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt52Codex,
name: 'GPT-5.2-Codex',
@@ -71,6 +84,45 @@ export const CODEX_MODELS: ModelDefinition[] = [
tier: 'basic' as const,
hasReasoning: false,
},
{
id: CODEX_MODEL_MAP.gpt51Codex,
name: 'GPT-5.1-Codex',
modelString: CODEX_MODEL_MAP.gpt51Codex,
provider: 'openai',
description: 'Original GPT-5.1 Codex agentic coding model.',
contextWindow: CONTEXT_WINDOW_256K,
maxOutputTokens: MAX_OUTPUT_32K,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt5Codex,
name: 'GPT-5-Codex',
modelString: CODEX_MODEL_MAP.gpt5Codex,
provider: 'openai',
description: 'Original GPT-5 Codex model.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt5CodexMini,
name: 'GPT-5-Codex-Mini',
modelString: CODEX_MODEL_MAP.gpt5CodexMini,
provider: 'openai',
description: 'Smaller, cheaper GPT-5 Codex variant.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
supportsTools: true,
tier: 'basic' as const,
hasReasoning: false,
},
// ========== General-Purpose GPT Models ==========
{
@@ -99,6 +151,19 @@ export const CODEX_MODELS: ModelDefinition[] = [
tier: 'standard' as const,
hasReasoning: true,
},
{
id: CODEX_MODEL_MAP.gpt5,
name: 'GPT-5',
modelString: CODEX_MODEL_MAP.gpt5,
provider: 'openai',
description: 'Base GPT-5 model.',
contextWindow: CONTEXT_WINDOW_128K,
maxOutputTokens: MAX_OUTPUT_16K,
supportsVision: true,
supportsTools: true,
tier: 'standard' as const,
hasReasoning: true,
},
];
/**

View File

@@ -30,7 +30,6 @@ import type {
ModelDefinition,
} from './types.js';
import {
CODEX_MODEL_MAP,
supportsReasoningEffort,
validateBareModelId,
calculateReasoningTimeout,
@@ -56,15 +55,10 @@ const CODEX_EXEC_SUBCOMMAND = 'exec';
const CODEX_JSON_FLAG = '--json';
const CODEX_MODEL_FLAG = '--model';
const CODEX_VERSION_FLAG = '--version';
const CODEX_SANDBOX_FLAG = '--sandbox';
const CODEX_APPROVAL_FLAG = '--ask-for-approval';
const CODEX_SEARCH_FLAG = '--search';
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
const CODEX_CONFIG_FLAG = '--config';
const CODEX_IMAGE_FLAG = '--image';
const CODEX_ADD_DIR_FLAG = '--add-dir';
const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema';
const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check';
const CODEX_RESUME_FLAG = 'resume';
const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort';
const CODEX_YOLO_FLAG = '--dangerously-bypass-approvals-and-sandbox';
const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY';
@@ -106,9 +100,6 @@ const TEXT_ENCODING = 'utf-8';
*/
const CODEX_CLI_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
const CODEX_FEATURE_GENERATION_BASE_TIMEOUT_MS = 300000; // 5 minutes for feature generation
const CONTEXT_WINDOW_256K = 256000;
const MAX_OUTPUT_32K = 32000;
const MAX_OUTPUT_16K = 16000;
const SYSTEM_PROMPT_SEPARATOR = '\n\n';
const CODEX_INSTRUCTIONS_DIR = '.codex';
const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions';
@@ -210,16 +201,42 @@ function isSdkEligible(options: ExecuteOptions): boolean {
return isNoToolsRequested(options) && !hasMcpServersConfigured(options);
}
function isSdkEligibleWithApiKey(options: ExecuteOptions): boolean {
// When using an API key (not CLI OAuth), prefer SDK over CLI to avoid OAuth issues.
// SDK mode is used when MCP servers are not configured (MCP requires CLI).
// Tool requests are handled by the SDK, so we allow SDK mode even with tools.
return !hasMcpServersConfigured(options);
}
async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<CodexExecutionPlan> {
const cliPath = await findCodexCliPath();
const authIndicators = await getCodexAuthIndicators();
const openAiApiKey = await resolveOpenAiApiKey();
const hasApiKey = Boolean(openAiApiKey);
const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey;
const sdkEligible = isSdkEligible(options);
const cliAvailable = Boolean(cliPath);
// CLI OAuth login takes priority: if the user has logged in via `codex login`,
// use the CLI regardless of whether an API key is also stored.
// hasOAuthToken = OAuth session from `codex login`
// authIndicators.hasApiKey = API key stored in Codex's own auth file (via `codex login --api-key`)
// Both are "CLI-native" auth — distinct from an API key stored in Automaker's credentials.
const hasCliNativeAuth = authIndicators.hasOAuthToken || authIndicators.hasApiKey;
const sdkEligible = isSdkEligible(options);
if (hasApiKey) {
// If CLI is available and the user authenticated via the CLI (`codex login`),
// prefer CLI mode over SDK. This ensures `codex login` sessions take priority
// over API keys stored in Automaker's credentials.
if (cliAvailable && hasCliNativeAuth) {
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
}
// No CLI-native auth — prefer SDK when an API key is available.
// Using SDK with an API key avoids OAuth issues that can arise with the CLI.
// MCP servers still require CLI mode since the SDK doesn't support MCP.
if (hasApiKey && isSdkEligibleWithApiKey(options)) {
return {
mode: CODEX_EXECUTION_MODE_SDK,
cliPath,
@@ -227,6 +244,16 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
};
}
// MCP servers are requested with an API key but no CLI-native auth — use CLI mode
// with the API key passed as an environment variable.
if (hasApiKey && cliAvailable) {
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
}
if (sdkEligible) {
if (!cliAvailable) {
throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED);
@@ -237,17 +264,11 @@ async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise<Codex
throw new Error(ERROR_CODEX_CLI_REQUIRED);
}
if (!cliAuthenticated) {
// At this point, neither hasCliNativeAuth nor hasApiKey is true,
// so authentication is required regardless.
throw new Error(ERROR_CODEX_AUTH_REQUIRED);
}
return {
mode: CODEX_EXECUTION_MODE_CLI,
cliPath,
openAiApiKey,
};
}
function getEventType(event: Record<string, unknown>): string | null {
if (typeof event.type === 'string') {
return event.type;
@@ -758,15 +779,12 @@ export class CodexProvider extends BaseProvider {
options.cwd,
codexSettings.sandboxMode !== 'danger-full-access'
);
const resolvedSandboxMode = sandboxCheck.enabled
? codexSettings.sandboxMode
: 'danger-full-access';
if (!sandboxCheck.enabled && sandboxCheck.message) {
console.warn(`[CodexProvider] ${sandboxCheck.message}`);
}
const searchEnabled =
codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools);
const outputSchemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat);
const schemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat);
const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : [];
const imagePaths = await writeImageFiles(options.cwd, imageBlocks);
const approvalPolicy =
@@ -801,7 +819,7 @@ export class CodexProvider extends BaseProvider {
overrides.push({ key: 'features.web_search_request', value: true });
}
const configOverrides = buildConfigOverrides(overrides);
const configOverrideArgs = buildConfigOverrides(overrides);
const preExecArgs: string[] = [];
// Add additional directories with write access
@@ -811,6 +829,12 @@ export class CodexProvider extends BaseProvider {
}
}
// If images were written to disk, add the image directory so the CLI can access them
if (imagePaths.length > 0) {
const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR);
preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir);
}
// Model is already bare (no prefix) - validated by executeQuery
const args = [
CODEX_EXEC_SUBCOMMAND,
@@ -820,6 +844,8 @@ export class CodexProvider extends BaseProvider {
CODEX_MODEL_FLAG,
options.model,
CODEX_JSON_FLAG,
...configOverrideArgs,
...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []),
'-', // Read prompt from stdin to avoid shell escaping issues
];
@@ -866,16 +892,36 @@ export class CodexProvider extends BaseProvider {
// Enhance error message with helpful context
let enhancedError = errorText;
if (errorText.toLowerCase().includes('rate limit')) {
const errorLower = errorText.toLowerCase();
if (errorLower.includes('rate limit')) {
enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`;
} else if (errorLower.includes('authentication') || errorLower.includes('unauthorized')) {
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex login' to authenticate.`;
} else if (
errorText.toLowerCase().includes('authentication') ||
errorText.toLowerCase().includes('unauthorized')
errorLower.includes('model does not exist') ||
errorLower.includes('requested model does not exist') ||
errorLower.includes('do not have access') ||
errorLower.includes('model_not_found') ||
errorLower.includes('invalid_model')
) {
enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex auth login' to authenticate.`;
enhancedError =
`${errorText}\n\nTip: The model '${options.model}' may not be available on your OpenAI plan. ` +
`See https://platform.openai.com/docs/models for available models. ` +
`Some models require a ChatGPT Pro/Plus subscription—authenticate with 'codex login' instead of an API key.`;
} else if (
errorText.toLowerCase().includes('not found') ||
errorText.toLowerCase().includes('command not found')
errorLower.includes('stream disconnected') ||
errorLower.includes('stream ended') ||
errorLower.includes('connection reset')
) {
enhancedError =
`${errorText}\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
`- Network instability\n` +
`- The model not being available on your plan\n` +
`- Server-side timeouts for long-running requests\n` +
`Try again, or switch to a different model.`;
} else if (
errorLower.includes('command not found') ||
errorLower.includes('is not recognized as an internal or external command')
) {
enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`;
}
@@ -1033,7 +1079,6 @@ export class CodexProvider extends BaseProvider {
async detectInstallation(): Promise<InstallationStatus> {
const cliPath = await findCodexCliPath();
const hasApiKey = Boolean(await resolveOpenAiApiKey());
const authIndicators = await getCodexAuthIndicators();
const installed = !!cliPath;
let version = '';
@@ -1045,7 +1090,7 @@ export class CodexProvider extends BaseProvider {
cwd: process.cwd(),
});
version = result.stdout.trim();
} catch (error) {
} catch {
version = '';
}
}

View File

@@ -15,6 +15,9 @@ const SDK_HISTORY_HEADER = 'Current request:\n';
const DEFAULT_RESPONSE_TEXT = '';
const SDK_ERROR_DETAILS_LABEL = 'Details:';
type SdkReasoningEffort = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
const SDK_REASONING_EFFORTS = new Set<string>(['minimal', 'low', 'medium', 'high', 'xhigh']);
type PromptBlock = {
type: string;
text?: string;
@@ -99,38 +102,52 @@ export async function* executeCodexSdkQuery(
const apiKey = resolveApiKey();
const codex = new Codex({ apiKey });
// Build thread options with model
// The model must be passed to startThread/resumeThread so the SDK
// knows which model to use for the conversation. Without this,
// the SDK may use a default model that the user doesn't have access to.
const threadOptions: {
model?: string;
modelReasoningEffort?: SdkReasoningEffort;
} = {};
if (options.model) {
threadOptions.model = options.model;
}
// Add reasoning effort to thread options if model supports it
if (
options.reasoningEffort &&
options.model &&
supportsReasoningEffort(options.model) &&
options.reasoningEffort !== 'none' &&
SDK_REASONING_EFFORTS.has(options.reasoningEffort)
) {
threadOptions.modelReasoningEffort = options.reasoningEffort as SdkReasoningEffort;
}
// Resume existing thread or start new one
let thread;
if (options.sdkSessionId) {
try {
thread = codex.resumeThread(options.sdkSessionId);
thread = codex.resumeThread(options.sdkSessionId, threadOptions);
} catch {
// If resume fails, start a new thread
thread = codex.startThread();
thread = codex.startThread(threadOptions);
}
} else {
thread = codex.startThread();
thread = codex.startThread(threadOptions);
}
const promptText = buildPromptText(options, systemPrompt);
// Build run options with reasoning effort if supported
// Build run options
const runOptions: {
signal?: AbortSignal;
reasoning?: { effort: string };
} = {
signal: options.abortController?.signal,
};
// Add reasoning effort if model supports it and reasoningEffort is specified
if (
options.reasoningEffort &&
supportsReasoningEffort(options.model) &&
options.reasoningEffort !== 'none'
) {
runOptions.reasoning = { effort: options.reasoningEffort };
}
// Run the query
const result = await thread.run(promptText, runOptions);
@@ -160,10 +177,42 @@ export async function* executeCodexSdkQuery(
} catch (error) {
const errorInfo = classifyError(error);
const userMessage = getUserFriendlyErrorMessage(error);
const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
let combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage);
// Enhance error messages with actionable tips for common Codex issues
// Normalize inputs to avoid crashes from nullish values
const errorLower = (errorInfo?.message ?? '').toLowerCase();
const modelLabel = options?.model ?? '<unknown model>';
if (
errorLower.includes('does not exist') ||
errorLower.includes('model_not_found') ||
errorLower.includes('invalid_model')
) {
// Model not found - provide helpful guidance
combinedMessage +=
`\n\nTip: The model '${modelLabel}' may not be available on your OpenAI plan. ` +
`Some models (like gpt-5.3-codex) require a ChatGPT Pro/Plus subscription and OAuth login via 'codex login'. ` +
`Try using a different model (e.g., gpt-5.1 or gpt-5.2), or authenticate with 'codex login' instead of an API key.`;
} else if (
errorLower.includes('stream disconnected') ||
errorLower.includes('stream ended') ||
errorLower.includes('connection reset') ||
errorLower.includes('socket hang up')
) {
// Stream disconnection - provide helpful guidance
combinedMessage +=
`\n\nTip: The connection to OpenAI was interrupted. This can happen due to:\n` +
`- Network instability\n` +
`- The model not being available on your plan (try 'codex login' for OAuth authentication)\n` +
`- Server-side timeouts for long-running requests\n` +
`Try again, or switch to a different model.`;
}
console.error('[CodexSDK] executeQuery() error during execution:', {
type: errorInfo.type,
message: errorInfo.message,
model: options.model,
isRateLimit: errorInfo.isRateLimit,
retryAfter: errorInfo.retryAfter,
stack: error instanceof Error ? error.stack : undefined,

View File

@@ -42,7 +42,7 @@ import {
const logger = createLogger('CopilotProvider');
// Default bare model (without copilot- prefix) for SDK calls
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.5';
const DEFAULT_BARE_MODEL = 'claude-sonnet-4.6';
// =============================================================================
// SDK Event Types (from @github/copilot-sdk)
@@ -85,10 +85,6 @@ interface SdkToolExecutionEndEvent extends SdkEvent {
};
}
interface SdkSessionIdleEvent extends SdkEvent {
type: 'session.idle';
}
interface SdkSessionErrorEvent extends SdkEvent {
type: 'session.error';
data: {

View File

@@ -31,7 +31,7 @@ import type {
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { validateApiKey } from '../lib/auth-utils.js';
import { getEffectivePermissions } from '../services/cursor-config-service.js';
import { getEffectivePermissions, detectProfile } from '../services/cursor-config-service.js';
import {
type CursorStreamEvent,
type CursorSystemEvent,
@@ -69,6 +69,7 @@ interface CursorToolHandler<TArgs = unknown, TResult = unknown> {
* Registry of Cursor tool handlers
* Each handler knows how to normalize its specific tool call type
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- handler registry stores heterogeneous tool type parameters
const CURSOR_TOOL_HANDLERS: Record<string, CursorToolHandler<any, any>> = {
readToolCall: {
name: 'Read',
@@ -877,8 +878,12 @@ export class CursorProvider extends CliProvider {
logger.debug(`CursorProvider.executeQuery called with model: "${options.model}"`);
// Get effective permissions for this project
// Get effective permissions for this project and detect the active profile
const effectivePermissions = await getEffectivePermissions(options.cwd || process.cwd());
const activeProfile = detectProfile(effectivePermissions);
logger.debug(
`Active permission profile: ${activeProfile ?? 'none'}, permissions: ${JSON.stringify(effectivePermissions)}`
);
// Debug: log raw events when AUTOMAKER_DEBUG_RAW_OUTPUT is enabled
const debugRawEvents =

View File

@@ -20,7 +20,6 @@ import type {
ProviderMessage,
InstallationStatus,
ModelDefinition,
ContentBlock,
} from './types.js';
import { validateBareModelId } from '@automaker/types';
import { GEMINI_MODEL_MAP, type GeminiAuthStatus } from '@automaker/types';

View File

@@ -192,6 +192,28 @@ export interface OpenCodeToolErrorEvent extends OpenCodeBaseEvent {
part?: OpenCodePart & { error: string };
}
/**
* Tool use event - The actual format emitted by OpenCode CLI when a tool is invoked.
* Contains the tool name, call ID, and the complete state (input, output, status).
* Note: OpenCode CLI emits 'tool_use' (not 'tool_call') as the event type.
*/
export interface OpenCodeToolUseEvent extends OpenCodeBaseEvent {
type: 'tool_use';
part: OpenCodePart & {
type: 'tool';
callID?: string;
tool?: string;
state?: {
status?: string;
input?: unknown;
output?: string;
title?: string;
metadata?: unknown;
time?: { start: number; end: number };
};
};
}
/**
* Union type of all OpenCode stream events
*/
@@ -200,6 +222,7 @@ export type OpenCodeStreamEvent =
| OpenCodeStepStartEvent
| OpenCodeStepFinishEvent
| OpenCodeToolCallEvent
| OpenCodeToolUseEvent
| OpenCodeToolResultEvent
| OpenCodeErrorEvent
| OpenCodeToolErrorEvent;
@@ -311,8 +334,8 @@ export class OpencodeProvider extends CliProvider {
* Arguments built:
* - 'run' subcommand for executing queries
* - '--format', 'json' for JSONL streaming output
* - '-c', '<cwd>' for working directory (using opencode's -c flag)
* - '--model', '<model>' for model selection (if specified)
* - '--session', '<id>' for continuing an existing session (if sdkSessionId is set)
*
* The prompt is passed via stdin (piped) to avoid shell escaping issues.
* OpenCode CLI automatically reads from stdin when input is piped.
@@ -326,6 +349,14 @@ export class OpencodeProvider extends CliProvider {
// Add JSON output format for JSONL parsing (not 'stream-json')
args.push('--format', 'json');
// Handle session resumption for conversation continuity.
// The opencode CLI supports `--session <id>` to continue an existing session.
// The sdkSessionId is captured from the sessionID field in previous stream events
// and persisted by AgentService for use in follow-up messages.
if (options.sdkSessionId) {
args.push('--session', options.sdkSessionId);
}
// Handle model selection
// Convert canonical prefix format (opencode-xxx) to CLI slash format (opencode/xxx)
// OpenCode CLI expects provider/model format (e.g., 'opencode/big-model')
@@ -398,15 +429,225 @@ export class OpencodeProvider extends CliProvider {
return subprocessOptions;
}
/**
* Check if an error message indicates a session-not-found condition.
*
* Centralizes the pattern matching for session errors to avoid duplication.
* Strips ANSI escape codes first since opencode CLI uses colored stderr output
* (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found").
*
* IMPORTANT: Patterns must be specific enough to avoid false positives.
* Generic patterns like "notfounderror" or "resource not found" match
* non-session errors (e.g. "ProviderModelNotFoundError") which would
* trigger unnecessary retries that fail identically, producing confusing
* error messages like "OpenCode session could not be created".
*
* @param errorText - Raw error text (may contain ANSI codes)
* @returns true if the error indicates the session was not found
*/
private static isSessionNotFoundError(errorText: string): boolean {
const cleaned = OpencodeProvider.stripAnsiCodes(errorText).toLowerCase();
// Explicit session-related phrases — high confidence
if (
cleaned.includes('session not found') ||
cleaned.includes('session does not exist') ||
cleaned.includes('invalid session') ||
cleaned.includes('session expired') ||
cleaned.includes('no such session')
) {
return true;
}
// Generic "NotFoundError" / "resource not found" are only session errors
// when the message also references a session path or session ID.
// Without this guard, errors like "ProviderModelNotFoundError" or
// "Resource not found: /path/to/config.json" would false-positive.
if (cleaned.includes('notfounderror') || cleaned.includes('resource not found')) {
return cleaned.includes('/session/') || /\bsession\b/.test(cleaned);
}
return false;
}
/**
* Strip ANSI escape codes from a string.
*
* The OpenCode CLI uses colored stderr output (e.g. "\x1b[91m\x1b[1mError: \x1b[0m").
* These escape codes render as garbled text like "[91m[1mError: [0m" in the UI
* when passed through as-is. This utility removes them so error messages are
* clean and human-readable.
*/
private static stripAnsiCodes(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, '');
}
/**
* Clean a CLI error message for display.
*
* Strips ANSI escape codes AND removes the redundant "Error: " prefix that
* the OpenCode CLI prepends to error messages in its colored stderr output
* (e.g. "\x1b[91m\x1b[1mError: \x1b[0mSession not found" → "Session not found").
*
* Without this, consumers that wrap the message in their own "Error: " prefix
* (like AgentService or AgentExecutor) produce garbled double-prefixed output:
* "Error: Error: Session not found".
*/
private static cleanErrorMessage(text: string): string {
let cleaned = OpencodeProvider.stripAnsiCodes(text).trim();
// Remove leading "Error: " prefix (case-insensitive) if present.
// The CLI formats errors as: \x1b[91m\x1b[1mError: \x1b[0m<actual message>
// After ANSI stripping this becomes: "Error: <actual message>"
cleaned = cleaned.replace(/^Error:\s*/i, '').trim();
return cleaned || text;
}
/**
* Execute a query with automatic session resumption fallback.
*
* When a sdkSessionId is provided, the CLI receives `--session <id>`.
* If the session no longer exists on disk the CLI will fail with a
* "NotFoundError" / "Resource not found" / "Session not found" error.
*
* The opencode CLI writes this to **stderr** and exits non-zero.
* `spawnJSONLProcess` collects stderr and **yields** it as
* `{ type: 'error', error: <stderrText> }` — it is NOT thrown.
* After `normalizeEvent`, the error becomes a yielded `ProviderMessage`
* with `type: 'error'`. A simple try/catch therefore cannot intercept it.
*
* This override iterates the parent stream, intercepts yielded error
* messages that match the session-not-found pattern, and retries the
* entire query WITHOUT the `--session` flag so a fresh session is started.
*
* Session-not-found retry is ONLY attempted when `sdkSessionId` is set.
* Without the `--session` flag the CLI always creates a fresh session, so
* retrying without it would be identical to the first attempt and would
* fail the same way — producing a confusing "session could not be created"
* message for what is actually a different error (model not found, auth
* failure, etc.).
*
* All error messages (session or not) are cleaned of ANSI codes and the
* CLI's redundant "Error: " prefix before being yielded to consumers.
*
* After a successful retry, the consumer (AgentService) will receive a new
* session_id from the fresh stream events, which it persists to metadata —
* replacing the stale sdkSessionId and preventing repeated failures.
*/
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
// When no sdkSessionId is set, there is nothing to "retry without" — just
// stream normally and clean error messages as they pass through.
if (!options.sdkSessionId) {
for await (const msg of super.executeQuery(options)) {
// Clean error messages so consumers don't get ANSI or double "Error:" prefix
if (msg.type === 'error' && msg.error && typeof msg.error === 'string') {
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
}
yield msg;
}
return;
}
// sdkSessionId IS set — the CLI will receive `--session <id>`.
// If that session no longer exists, intercept the error and retry fresh.
//
// To avoid buffering the entire stream in memory for long-lived sessions,
// we only buffer an initial window of messages until we observe a healthy
// (non-error) message. Once a healthy message is seen, we flush the buffer
// and switch to direct passthrough, while still watching for session errors
// via isSessionNotFoundError on any subsequent error messages.
const buffered: ProviderMessage[] = [];
let sessionError = false;
let seenHealthyMessage = false;
try {
for await (const msg of super.executeQuery(options)) {
if (msg.type === 'error') {
const errorText = msg.error || '';
if (OpencodeProvider.isSessionNotFoundError(errorText)) {
sessionError = true;
opencodeLogger.info(
`OpenCode session error detected (session "${options.sdkSessionId}") ` +
`— retrying without --session to start fresh`
);
break; // stop consuming the failed stream
}
// Non-session error — clean it
if (msg.error && typeof msg.error === 'string') {
msg.error = OpencodeProvider.cleanErrorMessage(msg.error);
}
} else {
// A non-error message is a healthy signal — stop buffering after this
seenHealthyMessage = true;
}
if (seenHealthyMessage && buffered.length > 0) {
// Flush the pre-healthy buffer first, then switch to passthrough
for (const bufferedMsg of buffered) {
yield bufferedMsg;
}
buffered.length = 0;
}
if (seenHealthyMessage) {
// Passthrough mode — yield directly without buffering
yield msg;
} else {
// Still in initial window — buffer until we see a healthy message
buffered.push(msg);
}
}
} catch (error) {
// Also handle thrown exceptions (e.g. from mapError in cli-provider)
const errMsg = error instanceof Error ? error.message : String(error);
if (OpencodeProvider.isSessionNotFoundError(errMsg)) {
sessionError = true;
opencodeLogger.info(
`OpenCode session error detected (thrown, session "${options.sdkSessionId}") ` +
`— retrying without --session to start fresh`
);
} else {
throw error;
}
}
if (sessionError) {
// Retry the entire query without the stale session ID.
const retryOptions = { ...options, sdkSessionId: undefined };
opencodeLogger.info('Retrying OpenCode query without --session flag...');
// Stream the retry directly to the consumer.
// If the retry also fails, it's a genuine error (not session-related)
// and should be surfaced as-is rather than masked with a misleading
// "session could not be created" message.
for await (const retryMsg of super.executeQuery(retryOptions)) {
if (retryMsg.type === 'error' && retryMsg.error && typeof retryMsg.error === 'string') {
retryMsg.error = OpencodeProvider.cleanErrorMessage(retryMsg.error);
}
yield retryMsg;
}
} else if (buffered.length > 0) {
// No session error and still have buffered messages (stream ended before
// any healthy message was observed) — flush them to the consumer
for (const msg of buffered) {
yield msg;
}
}
// If seenHealthyMessage is true, all messages have already been yielded
// directly in passthrough mode — nothing left to flush.
}
/**
* Normalize a raw CLI event to ProviderMessage format
*
* Maps OpenCode event types to the standard ProviderMessage structure:
* - text -> type: 'assistant', content with type: 'text'
* - step_start -> null (informational, no message needed)
* - step_finish with reason 'stop' -> type: 'result', subtype: 'success'
* - step_finish with reason 'stop'/'end_turn' -> type: 'result', subtype: 'success'
* - step_finish with reason 'tool-calls' -> null (intermediate step, not final)
* - step_finish with error -> type: 'error'
* - tool_call -> type: 'assistant', content with type: 'tool_use'
* - tool_use -> type: 'assistant', content with type: 'tool_use' (OpenCode CLI format)
* - tool_call -> type: 'assistant', content with type: 'tool_use' (legacy format)
* - tool_result -> type: 'assistant', content with type: 'tool_result'
* - error -> type: 'error'
*
@@ -459,7 +700,7 @@ export class OpencodeProvider extends CliProvider {
return {
type: 'error',
session_id: finishEvent.sessionID,
error: finishEvent.part.error,
error: OpencodeProvider.cleanErrorMessage(finishEvent.part.error),
};
}
@@ -468,11 +709,26 @@ export class OpencodeProvider extends CliProvider {
return {
type: 'error',
session_id: finishEvent.sessionID,
error: 'Step execution failed',
error: OpencodeProvider.cleanErrorMessage('Step execution failed'),
};
}
// Successful completion (reason: 'stop' or 'end_turn')
// Intermediate step completion (reason: 'tool-calls') — the agent loop
// is continuing because the model requested tool calls. Skip these so
// consumers don't mistake them for final results.
if (finishEvent.part?.reason === 'tool-calls') {
return null;
}
// Only treat an explicit allowlist of reasons as true success.
// Reasons like 'length' (context-window truncation) or 'content-filter'
// indicate the model stopped abnormally and must not be surfaced as
// successful completions.
const SUCCESS_REASONS = new Set(['stop', 'end_turn']);
const reason = finishEvent.part?.reason;
if (reason === undefined || SUCCESS_REASONS.has(reason)) {
// Final completion (reason: 'stop', 'end_turn', or unset)
return {
type: 'result',
subtype: 'success',
@@ -481,11 +737,23 @@ export class OpencodeProvider extends CliProvider {
};
}
// Non-success, non-tool-calls reason (e.g. 'length', 'content-filter')
return {
type: 'result',
subtype: 'error',
session_id: finishEvent.sessionID,
error: `Step finished with non-success reason: ${reason}`,
result: (finishEvent.part as OpenCodePart & { result?: string })?.result,
};
}
case 'tool_error': {
const toolErrorEvent = openCodeEvent as OpenCodeBaseEvent;
// Extract error message from part.error
const errorMessage = toolErrorEvent.part?.error || 'Tool execution failed';
// Extract error message from part.error and clean ANSI codes
const errorMessage = OpencodeProvider.cleanErrorMessage(
toolErrorEvent.part?.error || 'Tool execution failed'
);
return {
type: 'error',
@@ -494,6 +762,45 @@ export class OpencodeProvider extends CliProvider {
};
}
// OpenCode CLI emits 'tool_use' events (not 'tool_call') when the model invokes a tool.
// The event format includes the tool name, call ID, and state with input/output.
// Handle both 'tool_use' (actual CLI format) and 'tool_call' (legacy/alternative) for robustness.
case 'tool_use': {
const toolUseEvent = openCodeEvent as OpenCodeToolUseEvent;
const part = toolUseEvent.part;
// Generate a tool use ID if not provided
const toolUseId = part?.callID || part?.call_id || generateToolUseId();
const toolName = part?.tool || part?.name || 'unknown';
const content: ContentBlock[] = [
{
type: 'tool_use',
name: toolName,
tool_use_id: toolUseId,
input: part?.state?.input || part?.args,
},
];
// If the tool has already completed (state.status === 'completed'), also emit the result
if (part?.state?.status === 'completed' && part?.state?.output) {
content.push({
type: 'tool_result',
tool_use_id: toolUseId,
content: part.state.output,
});
}
return {
type: 'assistant',
session_id: toolUseEvent.sessionID,
message: {
role: 'assistant',
content,
},
};
}
case 'tool_call': {
const toolEvent = openCodeEvent as OpenCodeToolCallEvent;
@@ -560,6 +867,13 @@ export class OpencodeProvider extends CliProvider {
errorMessage = errorEvent.part.error;
}
// Clean error messages: strip ANSI escape codes AND the redundant "Error: "
// prefix the CLI adds. The OpenCode CLI outputs colored stderr like:
// \x1b[91m\x1b[1mError: \x1b[0mSession not found
// Without cleaning, consumers that wrap in their own "Error: " prefix
// produce "Error: Error: Session not found".
errorMessage = OpencodeProvider.cleanErrorMessage(errorMessage);
return {
type: 'error',
session_id: errorEvent.sessionID,
@@ -623,9 +937,9 @@ export class OpencodeProvider extends CliProvider {
default: true,
},
{
id: 'opencode/glm-4.7-free',
name: 'GLM 4.7 Free',
modelString: 'opencode/glm-4.7-free',
id: 'opencode/glm-5-free',
name: 'GLM 5 Free',
modelString: 'opencode/glm-5-free',
provider: 'opencode',
description: 'OpenCode free tier GLM model',
supportsTools: true,
@@ -643,19 +957,19 @@ export class OpencodeProvider extends CliProvider {
tier: 'basic',
},
{
id: 'opencode/grok-code',
name: 'Grok Code (Free)',
modelString: 'opencode/grok-code',
id: 'opencode/kimi-k2.5-free',
name: 'Kimi K2.5 Free',
modelString: 'opencode/kimi-k2.5-free',
provider: 'opencode',
description: 'OpenCode free tier Grok model for coding',
description: 'OpenCode free tier Kimi model for coding',
supportsTools: true,
supportsVision: false,
tier: 'basic',
},
{
id: 'opencode/minimax-m2.1-free',
name: 'MiniMax M2.1 Free',
modelString: 'opencode/minimax-m2.1-free',
id: 'opencode/minimax-m2.5-free',
name: 'MiniMax M2.5 Free',
modelString: 'opencode/minimax-m2.5-free',
provider: 'opencode',
description: 'OpenCode free tier MiniMax model',
supportsTools: true,
@@ -777,7 +1091,7 @@ export class OpencodeProvider extends CliProvider {
*
* OpenCode CLI output format (one model per line):
* opencode/big-pickle
* opencode/glm-4.7-free
* opencode/glm-5-free
* anthropic/claude-3-5-haiku-20241022
* github-copilot/claude-3.5-sonnet
* ...

View File

@@ -16,8 +16,6 @@
import { ProviderFactory } from './provider-factory.js';
import type {
ProviderMessage,
ContentBlock,
ThinkingLevel,
ReasoningEffort,
ClaudeApiProfile,
@@ -96,7 +94,7 @@ export interface StreamingQueryOptions extends SimpleQueryOptions {
/**
* Default model to use when none specified
*/
const DEFAULT_MODEL = 'claude-sonnet-4-20250514';
const DEFAULT_MODEL = 'claude-sonnet-4-6';
/**
* Execute a simple query and return the text result

View File

@@ -16,7 +16,7 @@ export function createHistoryHandler(agentService: AgentService) {
return;
}
const result = agentService.getHistory(sessionId);
const result = await agentService.getHistory(sessionId);
res.json(result);
} catch (error) {
logError(error, 'Get history failed');

View File

@@ -19,7 +19,7 @@ export function createQueueListHandler(agentService: AgentService) {
return;
}
const result = agentService.getQueue(sessionId);
const result = await agentService.getQueue(sessionId);
res.json(result);
} catch (error) {
logError(error, 'List queue failed');

View File

@@ -53,7 +53,15 @@ export function createSendHandler(agentService: AgentService) {
thinkingLevel,
})
.catch((error) => {
logger.error('Background error in sendMessage():', error);
const errorMsg = (error as Error).message || 'Unknown error';
logger.error(`Background error in sendMessage() for session ${sessionId}:`, errorMsg);
// Emit error via WebSocket so the UI is notified even though
// the HTTP response already returned 200. This is critical for
// session-not-found errors where sendMessage() throws before it
// can emit its own error event (no in-memory session to emit from).
agentService.emitSessionError(sessionId, errorMsg);
logError(error, 'Send message failed (background)');
});

View File

@@ -6,7 +6,7 @@ import type { Request, Response } from 'express';
import { AgentService } from '../../../services/agent-service.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
const logger = createLogger('Agent');
const _logger = createLogger('Agent');
export function createStartHandler(agentService: AgentService) {
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -128,7 +128,7 @@ export function logAuthStatus(context: string): void {
*/
export function logError(error: unknown, context: string): void {
logger.error(`${context}:`);
logger.error('Error name:', (error as any)?.name);
logger.error('Error name:', (error as Error)?.name);
logger.error('Error message:', (error as Error)?.message);
logger.error('Error stack:', (error as Error)?.stack);
logger.error('Full error object:', JSON.stringify(error, Object.getOwnPropertyNames(error), 2));

View File

@@ -30,7 +30,7 @@ const DEFAULT_MAX_FEATURES = 50;
* Timeout for Codex models when generating features (5 minutes).
* Codex models are slower and need more time to generate 50+ features.
*/
const CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
const _CODEX_FEATURE_GENERATION_TIMEOUT_MS = 300000; // 5 minutes
/**
* Type for extracted features JSON response

View File

@@ -29,7 +29,6 @@ import {
updateTechnologyStack,
updateRoadmapPhaseStatus,
type ImplementedFeature,
type RoadmapPhase,
} from '../../lib/xml-extractor.js';
import { getNotificationService } from '../../services/notification-service.js';

View File

@@ -26,23 +26,9 @@ export function createRunFeatureHandler(autoModeService: AutoModeServiceCompat)
return;
}
// Check per-worktree capacity before starting
const capacity = await autoModeService.checkWorktreeCapacity(projectPath, featureId);
if (!capacity.hasCapacity) {
const worktreeDesc = capacity.branchName
? `worktree "${capacity.branchName}"`
: 'main worktree';
res.status(429).json({
success: false,
error: `Agent limit reached for ${worktreeDesc} (${capacity.currentAgents}/${capacity.maxAgents}). Wait for running tasks to complete or increase the limit.`,
details: {
currentAgents: capacity.currentAgents,
maxAgents: capacity.maxAgents,
branchName: capacity.branchName,
},
});
return;
}
// Note: No concurrency limit check here. Manual feature starts always run
// immediately and bypass the concurrency limit. Their presence IS counted
// by the auto-loop coordinator when deciding whether to dispatch new auto-mode tasks.
// Start execution in background
// executeFeature derives workDir from feature.branchName

View File

@@ -6,7 +6,7 @@
*/
import type { EventEmitter } from '../../lib/events.js';
import type { Feature, BacklogPlanResult, BacklogChange, DependencyUpdate } from '@automaker/types';
import type { Feature, BacklogPlanResult } from '@automaker/types';
import {
DEFAULT_PHASE_MODELS,
isCursorModel,

View File

@@ -3,7 +3,7 @@
*/
import type { Request, Response } from 'express';
import type { BacklogPlanResult, BacklogChange, Feature } from '@automaker/types';
import type { BacklogPlanResult } from '@automaker/types';
import { FeatureLoader } from '../../../services/feature-loader.js';
import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js';
@@ -58,6 +58,9 @@ export function createApplyHandler() {
if (feature.dependencies?.includes(change.featureId)) {
const newDeps = feature.dependencies.filter((d) => d !== change.featureId);
await featureLoader.update(projectPath, feature.id, { dependencies: newDeps });
// Mutate the in-memory feature object so subsequent deletions use the updated
// dependency list and don't reintroduce already-removed dependency IDs.
feature.dependencies = newDeps;
logger.info(
`[BacklogPlan] Removed dependency ${change.featureId} from ${feature.id}`
);

View File

@@ -33,6 +33,11 @@ export function createFeaturesRoutes(
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.get(
'/list',
validatePathParams('projectPath'),
createListHandler(featureLoader, autoModeService)
);
router.post('/get', validatePathParams('projectPath'), createGetHandler(featureLoader));
router.post(
'/create',

View File

@@ -36,7 +36,7 @@ interface ExportRequest {
};
}
export function createExportHandler(featureLoader: FeatureLoader) {
export function createExportHandler(_featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -34,7 +34,7 @@ export function createGenerateTitleHandler(
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { description, projectPath } = req.body as GenerateTitleRequestBody;
const { description } = req.body as GenerateTitleRequestBody;
if (!description || typeof description !== 'string') {
const response: GenerateTitleErrorResponse = {

View File

@@ -33,7 +33,7 @@ interface ConflictInfo {
hasConflict: boolean;
}
export function createImportHandler(featureLoader: FeatureLoader) {
export function createImportHandler(_featureLoader: FeatureLoader) {
const exportService = getFeatureExportService();
return async (req: Request, res: Response): Promise<void> => {

View File

@@ -1,5 +1,7 @@
/**
* POST /list endpoint - List all features for a project
* POST/GET /list endpoint - List all features for a project
*
* projectPath may come from req.body (POST) or req.query (GET fallback).
*
* Also performs orphan detection when a project is loaded to identify
* features whose branches no longer exist. This runs on every project load/switch.
@@ -19,7 +21,17 @@ export function createListHandler(
) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
const bodyProjectPath =
typeof req.body === 'object' && req.body !== null
? (req.body as { projectPath?: unknown }).projectPath
: undefined;
const queryProjectPath = req.query.projectPath;
const projectPath =
typeof bodyProjectPath === 'string'
? bodyProjectPath
: typeof queryProjectPath === 'string'
? queryProjectPath
: undefined;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });

View File

@@ -19,6 +19,10 @@ import { createBrowseHandler } from './routes/browse.js';
import { createImageHandler } from './routes/image.js';
import { createSaveBoardBackgroundHandler } from './routes/save-board-background.js';
import { createDeleteBoardBackgroundHandler } from './routes/delete-board-background.js';
import { createBrowseProjectFilesHandler } from './routes/browse-project-files.js';
import { createCopyHandler } from './routes/copy.js';
import { createMoveHandler } from './routes/move.js';
import { createDownloadHandler } from './routes/download.js';
export function createFsRoutes(_events: EventEmitter): Router {
const router = Router();
@@ -37,6 +41,10 @@ export function createFsRoutes(_events: EventEmitter): Router {
router.get('/image', createImageHandler());
router.post('/save-board-background', createSaveBoardBackgroundHandler());
router.post('/delete-board-background', createDeleteBoardBackgroundHandler());
router.post('/browse-project-files', createBrowseProjectFilesHandler());
router.post('/copy', createCopyHandler());
router.post('/move', createMoveHandler());
router.post('/download', createDownloadHandler());
return router;
}

View File

@@ -0,0 +1,191 @@
/**
* POST /browse-project-files endpoint - Browse files and directories within a project
*
* Unlike /browse which only lists directories (for project folder selection),
* this endpoint lists both files and directories relative to a project root.
* Used by the file selector for "Copy files to worktree" settings.
*
* Features:
* - Lists both files and directories
* - Hides .git, .worktrees, node_modules, and other build artifacts
* - Returns entries relative to the project root
* - Supports navigating into subdirectories
* - Security: prevents path traversal outside project root
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
// Directories to hide from the listing (build artifacts, caches, etc.)
const HIDDEN_DIRECTORIES = new Set([
'.git',
'.worktrees',
'node_modules',
'.automaker',
'__pycache__',
'.cache',
'.next',
'.nuxt',
'.svelte-kit',
'.turbo',
'.vercel',
'.output',
'coverage',
'.nyc_output',
'dist',
'build',
'out',
'.tmp',
'tmp',
'.venv',
'venv',
'target',
'vendor',
'.gradle',
'.idea',
'.vscode',
]);
interface ProjectFileEntry {
name: string;
relativePath: string;
isDirectory: boolean;
isFile: boolean;
}
export function createBrowseProjectFilesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, relativePath } = req.body as {
projectPath: string;
relativePath?: string; // Relative path within the project to browse (empty = project root)
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
const resolvedProjectPath = path.resolve(projectPath);
// Determine the target directory to browse
let targetPath = resolvedProjectPath;
let currentRelativePath = '';
if (relativePath) {
// Security: normalize and validate the relative path
const normalized = path.normalize(relativePath);
if (normalized.startsWith('..') || path.isAbsolute(normalized)) {
res.status(400).json({
success: false,
error: 'Invalid relative path - must be within the project directory',
});
return;
}
targetPath = path.join(resolvedProjectPath, normalized);
currentRelativePath = normalized;
// Double-check the resolved path is within the project
// Use a separator-terminated prefix to prevent matching sibling dirs
// that share the same prefix (e.g. /projects/foo vs /projects/foobar).
const resolvedTarget = path.resolve(targetPath);
const projectPrefix = resolvedProjectPath.endsWith(path.sep)
? resolvedProjectPath
: resolvedProjectPath + path.sep;
if (!resolvedTarget.startsWith(projectPrefix) && resolvedTarget !== resolvedProjectPath) {
res.status(400).json({
success: false,
error: 'Path traversal detected',
});
return;
}
}
// Determine parent relative path
let parentRelativePath: string | null = null;
if (currentRelativePath) {
const parent = path.dirname(currentRelativePath);
parentRelativePath = parent === '.' ? '' : parent;
}
try {
const stat = await secureFs.stat(targetPath);
if (!stat.isDirectory()) {
res.status(400).json({ success: false, error: 'Path is not a directory' });
return;
}
// Read directory contents
const dirEntries = await secureFs.readdir(targetPath, { withFileTypes: true });
// Filter and map entries
const entries: ProjectFileEntry[] = dirEntries
.filter((entry) => {
// Skip hidden directories (build artifacts, etc.)
if (entry.isDirectory() && HIDDEN_DIRECTORIES.has(entry.name)) {
return false;
}
// Skip entries starting with . (hidden files) except common config files
// We keep hidden files visible since users often need .env, .eslintrc, etc.
return true;
})
.map((entry) => {
const entryRelativePath = currentRelativePath
? path.posix.join(currentRelativePath.replace(/\\/g, '/'), entry.name)
: entry.name;
return {
name: entry.name,
relativePath: entryRelativePath,
isDirectory: entry.isDirectory(),
isFile: entry.isFile(),
};
})
// Sort: directories first, then files, alphabetically within each group
.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
res.json({
success: true,
currentRelativePath,
parentRelativePath,
entries,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to read directory';
const isPermissionError = errorMessage.includes('EPERM') || errorMessage.includes('EACCES');
if (isPermissionError) {
res.json({
success: true,
currentRelativePath,
parentRelativePath,
entries: [],
warning: 'Permission denied - unable to read this directory',
});
} else {
res.status(400).json({
success: false,
error: errorMessage,
});
}
}
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Browse project files failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,99 @@
/**
* POST /copy endpoint - Copy file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
/**
* Recursively copy a directory and its contents
*/
async function copyDirectoryRecursive(src: string, dest: string): Promise<void> {
await mkdirSafe(dest);
const entries = await secureFs.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await copyDirectoryRecursive(srcPath, destPath);
} else {
await secureFs.copyFile(srcPath, destPath);
}
}
}
export function createCopyHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent copying a folder into itself or its own descendant (infinite recursion)
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc || resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot copy a folder into itself or one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first to avoid merging
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Check if source is a directory
const stats = await secureFs.stat(sourcePath);
if (stats.isDirectory()) {
await copyDirectoryRecursive(sourcePath, destinationPath);
} else {
await secureFs.copyFile(sourcePath, destinationPath);
}
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Copy file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,142 @@
/**
* POST /download endpoint - Download a file, or GET /download for streaming
* For folders, creates a zip archive on the fly
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
import { createReadStream } from 'fs';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { tmpdir } from 'os';
const execFileAsync = promisify(execFile);
/**
* Get total size of a directory recursively
*/
async function getDirectorySize(dirPath: string): Promise<number> {
let totalSize = 0;
const entries = await secureFs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
totalSize += await getDirectorySize(entryPath);
} else {
const stats = await secureFs.stat(entryPath);
totalSize += Number(stats.size);
}
}
return totalSize;
}
export function createDownloadHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { filePath } = req.body as { filePath: string };
if (!filePath) {
res.status(400).json({ success: false, error: 'filePath is required' });
return;
}
const stats = await secureFs.stat(filePath);
const fileName = path.basename(filePath);
if (stats.isDirectory()) {
// For directories, create a zip archive
const dirSize = await getDirectorySize(filePath);
const MAX_DIR_SIZE = 100 * 1024 * 1024; // 100MB limit
if (dirSize > MAX_DIR_SIZE) {
res.status(413).json({
success: false,
error: `Directory is too large to download (${(dirSize / (1024 * 1024)).toFixed(1)}MB). Maximum size is ${MAX_DIR_SIZE / (1024 * 1024)}MB.`,
size: dirSize,
});
return;
}
// Create a temporary zip file
const zipFileName = `${fileName}.zip`;
const tmpZipPath = path.join(tmpdir(), `automaker-download-${Date.now()}-${zipFileName}`);
try {
// Use system zip command (available on macOS and Linux)
// Use execFile to avoid shell injection via user-provided paths
await execFileAsync('zip', ['-r', tmpZipPath, fileName], {
cwd: path.dirname(filePath),
maxBuffer: 50 * 1024 * 1024,
});
const zipStats = await secureFs.stat(tmpZipPath);
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipFileName}"`);
res.setHeader('Content-Length', zipStats.size.toString());
res.setHeader('X-Directory-Size', dirSize.toString());
const stream = createReadStream(tmpZipPath);
stream.pipe(res);
stream.on('end', async () => {
// Cleanup temp file
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
});
stream.on('error', async (err) => {
logError(err, 'Download stream error');
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore cleanup errors
}
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
} catch (zipError) {
// Cleanup on zip failure
try {
await secureFs.rm(tmpZipPath);
} catch {
// Ignore
}
throw zipError;
}
} else {
// For individual files, stream directly
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
res.setHeader('Content-Length', stats.size.toString());
const stream = createReadStream(filePath);
stream.pipe(res);
stream.on('error', (err) => {
logError(err, 'Download stream error');
if (!res.headersSent) {
res.status(500).json({ success: false, error: 'Stream error during download' });
}
});
}
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Download failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -35,9 +35,9 @@ export function createMkdirHandler() {
error: 'Path exists and is not a directory',
});
return;
} catch (statError: any) {
} catch (statError: unknown) {
// ENOENT means path doesn't exist - we should create it
if (statError.code !== 'ENOENT') {
if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') {
// Some other error (could be ELOOP in parent path)
throw statError;
}
@@ -47,7 +47,7 @@ export function createMkdirHandler() {
await secureFs.mkdir(resolvedPath, { recursive: true });
res.json({ success: true });
} catch (error: any) {
} catch (error: unknown) {
// Path not allowed - return 403 Forbidden
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
@@ -55,7 +55,7 @@ export function createMkdirHandler() {
}
// Handle ELOOP specifically
if (error.code === 'ELOOP') {
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
logError(error, 'Create directory failed - symlink loop detected');
res.status(400).json({
success: false,

View File

@@ -0,0 +1,79 @@
/**
* POST /move endpoint - Move (rename) file or directory to a new location
*/
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { PathNotAllowedError } from '@automaker/platform';
import { mkdirSafe } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
export function createMoveHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { sourcePath, destinationPath, overwrite } = req.body as {
sourcePath: string;
destinationPath: string;
overwrite?: boolean;
};
if (!sourcePath || !destinationPath) {
res
.status(400)
.json({ success: false, error: 'sourcePath and destinationPath are required' });
return;
}
// Prevent moving to same location or into its own descendant
const resolvedSrc = path.resolve(sourcePath);
const resolvedDest = path.resolve(destinationPath);
if (resolvedDest === resolvedSrc) {
// No-op: source and destination are the same
res.json({ success: true });
return;
}
if (resolvedDest.startsWith(resolvedSrc + path.sep)) {
res.status(400).json({
success: false,
error: 'Cannot move a folder into one of its own descendants',
});
return;
}
// Check if destination already exists
try {
await secureFs.stat(destinationPath);
// Destination exists
if (!overwrite) {
res.status(409).json({
success: false,
error: 'Destination already exists',
exists: true,
});
return;
}
// If overwrite is true, remove the existing destination first
await secureFs.rm(destinationPath, { recursive: true });
} catch {
// Destination doesn't exist - good to proceed
}
// Ensure parent directory exists
await mkdirSafe(path.dirname(path.resolve(destinationPath)));
// Use rename for the move operation
await secureFs.rename(sourcePath, destinationPath);
res.json({ success: true });
} catch (error) {
if (error instanceof PathNotAllowedError) {
res.status(403).json({ success: false, error: getErrorMessage(error) });
return;
}
logError(error, 'Move file failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -10,7 +10,11 @@ import { getErrorMessage, logError } from '../common.js';
export function createResolveDirectoryHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { directoryName, sampleFiles, fileCount } = req.body as {
const {
directoryName,
sampleFiles,
fileCount: _fileCount,
} = req.body as {
directoryName: string;
sampleFiles?: string[];
fileCount?: number;

View File

@@ -11,10 +11,9 @@ import { getBoardDir } from '@automaker/platform';
export function createSaveBoardBackgroundHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { data, filename, mimeType, projectPath } = req.body as {
const { data, filename, projectPath } = req.body as {
data: string;
filename: string;
mimeType: string;
projectPath: string;
};

View File

@@ -12,10 +12,9 @@ import { sanitizeFilename } from '@automaker/utils';
export function createSaveImageHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { data, filename, mimeType, projectPath } = req.body as {
const { data, filename, projectPath } = req.body as {
data: string;
filename: string;
mimeType: string;
projectPath: string;
};

View File

@@ -5,7 +5,7 @@
import type { Request, Response } from 'express';
import * as secureFs from '../../../lib/secure-fs.js';
import path from 'path';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
import { isPathAllowed, getAllowedRootDirectory } from '@automaker/platform';
import { getErrorMessage, logError } from '../common.js';
export function createValidatePathHandler() {

View File

@@ -0,0 +1,66 @@
import { Router, Request, Response } from 'express';
import { GeminiProvider } from '../../providers/gemini-provider.js';
import { GeminiUsageService } from '../../services/gemini-usage-service.js';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../lib/events.js';
const logger = createLogger('Gemini');
export function createGeminiRoutes(
usageService: GeminiUsageService,
_events: EventEmitter
): Router {
const router = Router();
// Get current usage/quota data from Google Cloud API
router.get('/usage', async (_req: Request, res: Response) => {
try {
const usageData = await usageService.fetchUsageData();
res.json(usageData);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error fetching Gemini usage:', error);
// Return error in a format the UI expects
res.status(200).json({
authenticated: false,
authMethod: 'none',
usedPercent: 0,
remainingPercent: 100,
lastUpdated: new Date().toISOString(),
error: `Failed to fetch Gemini usage: ${message}`,
});
}
});
// Check if Gemini is available
router.get('/status', async (_req: Request, res: Response) => {
try {
const provider = new GeminiProvider();
const status = await provider.detectInstallation();
// Derive authMethod from typed InstallationStatus fields
const authMethod = status.authenticated
? status.hasApiKey
? 'api_key'
: 'cli_login'
: 'none';
res.json({
success: true,
installed: status.installed,
version: status.version || null,
path: status.path || null,
authenticated: status.authenticated || false,
authMethod,
hasCredentialsFile: false,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -6,12 +6,22 @@ import { Router } from 'express';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { createDiffsHandler } from './routes/diffs.js';
import { createFileDiffHandler } from './routes/file-diff.js';
import { createStageFilesHandler } from './routes/stage-files.js';
import { createDetailsHandler } from './routes/details.js';
import { createEnhancedStatusHandler } from './routes/enhanced-status.js';
export function createGitRoutes(): Router {
const router = Router();
router.post('/diffs', validatePathParams('projectPath'), createDiffsHandler());
router.post('/file-diff', validatePathParams('projectPath', 'filePath'), createFileDiffHandler());
router.post(
'/stage-files',
validatePathParams('projectPath', 'files[]'),
createStageFilesHandler()
);
router.post('/details', validatePathParams('projectPath', 'filePath?'), createDetailsHandler());
router.post('/enhanced-status', validatePathParams('projectPath'), createEnhancedStatusHandler());
return router;
}

View File

@@ -0,0 +1,248 @@
/**
* POST /details endpoint - Get detailed git info for a file or project
* Returns branch, last commit info, diff stats, and conflict status
*/
import type { Request, Response } from 'express';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import * as secureFs from '../../../lib/secure-fs.js';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface GitFileDetails {
branch: string;
lastCommitHash: string;
lastCommitMessage: string;
lastCommitAuthor: string;
lastCommitTimestamp: string;
linesAdded: number;
linesRemoved: number;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
statusLabel: string;
}
export function createDetailsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, filePath } = req.body as {
projectPath: string;
filePath?: string;
};
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
try {
// Get current branch
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
const branch = branchRaw.trim();
if (!filePath) {
// Project-level details - just return branch info
res.json({
success: true,
details: { branch },
});
return;
}
// Get last commit info for this file
let lastCommitHash = '';
let lastCommitMessage = '';
let lastCommitAuthor = '';
let lastCommitTimestamp = '';
try {
const { stdout: logOutput } = await execFileAsync(
'git',
['log', '-1', '--format=%H|%s|%an|%aI', '--', filePath],
{ cwd: projectPath }
);
if (logOutput.trim()) {
const parts = logOutput.trim().split('|');
lastCommitHash = parts[0] || '';
lastCommitMessage = parts[1] || '';
lastCommitAuthor = parts[2] || '';
lastCommitTimestamp = parts[3] || '';
}
} catch {
// File may not have any commits yet
}
// Get diff stats (lines added/removed)
let linesAdded = 0;
let linesRemoved = 0;
try {
// Check if file is untracked first
const { stdout: statusLine } = await execFileAsync(
'git',
['status', '--porcelain', '--', filePath],
{ cwd: projectPath }
);
if (statusLine.trim().startsWith('??')) {
// Untracked file - count all lines as added using Node.js instead of shell
try {
const fileContent = (await secureFs.readFile(filePath, 'utf-8')).toString();
const lines = fileContent.split('\n');
// Don't count trailing empty line from final newline
linesAdded =
lines.length > 0 && lines[lines.length - 1] === ''
? lines.length - 1
: lines.length;
} catch {
// Ignore
}
} else {
const { stdout: diffStatRaw } = await execFileAsync(
'git',
['diff', '--numstat', 'HEAD', '--', filePath],
{ cwd: projectPath }
);
if (diffStatRaw.trim()) {
const parts = diffStatRaw.trim().split('\t');
linesAdded = parseInt(parts[0], 10) || 0;
linesRemoved = parseInt(parts[1], 10) || 0;
}
// Also check staged diff stats
const { stdout: stagedDiffStatRaw } = await execFileAsync(
'git',
['diff', '--numstat', '--cached', '--', filePath],
{ cwd: projectPath }
);
if (stagedDiffStatRaw.trim()) {
const parts = stagedDiffStatRaw.trim().split('\t');
linesAdded += parseInt(parts[0], 10) || 0;
linesRemoved += parseInt(parts[1], 10) || 0;
}
}
} catch {
// Diff might not be available
}
// Get conflict and staging status
let isConflicted = false;
let isStaged = false;
let isUnstaged = false;
let statusLabel = '';
try {
const { stdout: statusOutput } = await execFileAsync(
'git',
['status', '--porcelain', '--', filePath],
{ cwd: projectPath }
);
if (statusOutput.trim()) {
const indexStatus = statusOutput[0];
const workTreeStatus = statusOutput[1];
// Check for conflicts (both modified, unmerged states)
if (
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D')
) {
isConflicted = true;
statusLabel = 'Conflicted';
} else {
// Staged changes (index has a status)
if (indexStatus !== ' ' && indexStatus !== '?') {
isStaged = true;
}
// Unstaged changes (work tree has a status)
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
isUnstaged = true;
}
// Build status label
if (isStaged && isUnstaged) {
statusLabel = 'Staged + Modified';
} else if (isStaged) {
statusLabel = 'Staged';
} else {
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
switch (statusChar) {
case 'M':
statusLabel = 'Modified';
break;
case 'A':
statusLabel = 'Added';
break;
case 'D':
statusLabel = 'Deleted';
break;
case 'R':
statusLabel = 'Renamed';
break;
case 'C':
statusLabel = 'Copied';
break;
case '?':
statusLabel = 'Untracked';
break;
default:
statusLabel = statusChar || '';
}
}
}
}
} catch {
// Status might not be available
}
const details: GitFileDetails = {
branch,
lastCommitHash,
lastCommitMessage,
lastCommitAuthor,
lastCommitTimestamp,
linesAdded,
linesRemoved,
isConflicted,
isStaged,
isUnstaged,
statusLabel,
};
res.json({ success: true, details });
} catch (innerError) {
logError(innerError, 'Git details failed');
res.json({
success: true,
details: {
branch: '',
lastCommitHash: '',
lastCommitMessage: '',
lastCommitAuthor: '',
lastCommitTimestamp: '',
linesAdded: 0,
linesRemoved: 0,
isConflicted: false,
isStaged: false,
isUnstaged: false,
statusLabel: '',
},
});
}
} catch (error) {
logError(error, 'Get git details failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -23,6 +23,7 @@ export function createDiffsHandler() {
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
...(result.mergeState ? { mergeState: result.mergeState } : {}),
});
} catch (innerError) {
logError(innerError, 'Git diff failed');

View File

@@ -0,0 +1,176 @@
/**
* POST /enhanced-status endpoint - Get enhanced git status with diff stats per file
* Returns per-file status with lines added/removed and staged/unstaged differentiation
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
interface EnhancedFileStatus {
path: string;
indexStatus: string;
workTreeStatus: string;
isConflicted: boolean;
isStaged: boolean;
isUnstaged: boolean;
linesAdded: number;
linesRemoved: number;
statusLabel: string;
}
function getStatusLabel(indexStatus: string, workTreeStatus: string): string {
// Check for conflicts
if (
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D')
) {
return 'Conflicted';
}
const hasStaged = indexStatus !== ' ' && indexStatus !== '?';
const hasUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
if (hasStaged && hasUnstaged) return 'Staged + Modified';
if (hasStaged) return 'Staged';
const statusChar = workTreeStatus !== ' ' ? workTreeStatus : indexStatus;
switch (statusChar) {
case 'M':
return 'Modified';
case 'A':
return 'Added';
case 'D':
return 'Deleted';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
case '?':
return 'Untracked';
default:
return statusChar || '';
}
}
export function createEnhancedStatusHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath } = req.body as { projectPath: string };
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
}
try {
// Get current branch
const { stdout: branchRaw } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: projectPath,
});
const branch = branchRaw.trim();
// Get porcelain status for all files
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: projectPath,
});
// Get diff numstat for working tree changes
let workTreeStats: Record<string, { added: number; removed: number }> = {};
try {
const { stdout: numstatRaw } = await execAsync('git diff --numstat', {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
for (const line of numstatRaw.trim().split('\n').filter(Boolean)) {
const parts = line.split('\t');
if (parts.length >= 3) {
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
workTreeStats[parts[2]] = { added, removed };
}
}
} catch {
// Ignore
}
// Get diff numstat for staged changes
let stagedStats: Record<string, { added: number; removed: number }> = {};
try {
const { stdout: stagedNumstatRaw } = await execAsync('git diff --numstat --cached', {
cwd: projectPath,
maxBuffer: 10 * 1024 * 1024,
});
for (const line of stagedNumstatRaw.trim().split('\n').filter(Boolean)) {
const parts = line.split('\t');
if (parts.length >= 3) {
const added = parseInt(parts[0], 10) || 0;
const removed = parseInt(parts[1], 10) || 0;
stagedStats[parts[2]] = { added, removed };
}
}
} catch {
// Ignore
}
// Parse status and build enhanced file list
const files: EnhancedFileStatus[] = [];
for (const line of statusOutput.split('\n').filter(Boolean)) {
if (line.length < 4) continue;
const indexStatus = line[0];
const workTreeStatus = line[1];
const filePath = line.substring(3).trim();
// Handle renamed files (format: "R old -> new")
const actualPath = filePath.includes(' -> ')
? filePath.split(' -> ')[1].trim()
: filePath;
const isConflicted =
indexStatus === 'U' ||
workTreeStatus === 'U' ||
(indexStatus === 'A' && workTreeStatus === 'A') ||
(indexStatus === 'D' && workTreeStatus === 'D');
const isStaged = indexStatus !== ' ' && indexStatus !== '?';
const isUnstaged = workTreeStatus !== ' ' && workTreeStatus !== '?';
// Combine diff stats from both working tree and staged
const wtStats = workTreeStats[actualPath] || { added: 0, removed: 0 };
const stStats = stagedStats[actualPath] || { added: 0, removed: 0 };
files.push({
path: actualPath,
indexStatus,
workTreeStatus,
isConflicted,
isStaged,
isUnstaged,
linesAdded: wtStats.added + stStats.added,
linesRemoved: wtStats.removed + stStats.removed,
statusLabel: getStatusLabel(indexStatus, workTreeStatus),
});
}
res.json({
success: true,
branch,
files,
});
} catch (innerError) {
logError(innerError, 'Git enhanced status failed');
res.json({ success: true, branch: '', files: [] });
}
} catch (error) {
logError(error, 'Get enhanced status failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,67 @@
/**
* POST /stage-files endpoint - Stage or unstage files in the main project
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js';
export function createStageFilesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, files, operation } = req.body as {
projectPath: string;
files: string[];
operation: 'stage' | 'unstage';
};
if (!projectPath) {
res.status(400).json({
success: false,
error: 'projectPath required',
});
return;
}
if (!Array.isArray(files) || files.length === 0) {
res.status(400).json({
success: false,
error: 'files array required and must not be empty',
});
return;
}
for (const file of files) {
if (typeof file !== 'string' || file.trim() === '') {
res.status(400).json({
success: false,
error: 'Each element of files must be a non-empty string',
});
return;
}
}
if (operation !== 'stage' && operation !== 'unstage') {
res.status(400).json({
success: false,
error: 'operation must be "stage" or "unstage"',
});
return;
}
const result = await stageFiles(projectPath, files, operation);
res.json({
success: true,
result,
});
} catch (error) {
if (error instanceof StageFilesValidationError) {
res.status(400).json({ success: false, error: error.message });
return;
}
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -9,6 +9,8 @@ import { createCheckGitHubRemoteHandler } from './routes/check-github-remote.js'
import { createListIssuesHandler } from './routes/list-issues.js';
import { createListPRsHandler } from './routes/list-prs.js';
import { createListCommentsHandler } from './routes/list-comments.js';
import { createListPRReviewCommentsHandler } from './routes/list-pr-review-comments.js';
import { createResolvePRCommentHandler } from './routes/resolve-pr-comment.js';
import { createValidateIssueHandler } from './routes/validate-issue.js';
import {
createValidationStatusHandler,
@@ -29,6 +31,16 @@ export function createGitHubRoutes(
router.post('/issues', validatePathParams('projectPath'), createListIssuesHandler());
router.post('/prs', validatePathParams('projectPath'), createListPRsHandler());
router.post('/issue-comments', validatePathParams('projectPath'), createListCommentsHandler());
router.post(
'/pr-review-comments',
validatePathParams('projectPath'),
createListPRReviewCommentsHandler()
);
router.post(
'/resolve-pr-comment',
validatePathParams('projectPath'),
createResolvePRCommentHandler()
);
router.post(
'/validate-issue',
validatePathParams('projectPath'),

View File

@@ -0,0 +1,333 @@
/**
* POST /pr-review-comments endpoint - Fetch review comments for a GitHub PR
*
* Fetches both regular PR comments and inline code review comments
* for a specific pull request, providing file path and line context.
*/
import { spawn } from 'child_process';
import type { Request, Response } from 'express';
import { execAsync, execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
export interface PRReviewComment {
id: string;
author: string;
avatarUrl?: string;
body: string;
path?: string;
line?: number;
createdAt: string;
updatedAt?: string;
isReviewComment: boolean;
/** Whether this is an outdated review comment (code has changed since) */
isOutdated?: boolean;
/** Whether the review thread containing this comment has been resolved */
isResolved?: boolean;
/** The GraphQL node ID of the review thread (used for resolve/unresolve mutations) */
threadId?: string;
/** The diff hunk context for the comment */
diffHunk?: string;
/** The side of the diff (LEFT or RIGHT) */
side?: string;
/** The commit ID the comment was made on */
commitId?: string;
}
export interface ListPRReviewCommentsResult {
success: boolean;
comments?: PRReviewComment[];
totalCount?: number;
error?: string;
}
interface ListPRReviewCommentsRequest {
projectPath: string;
prNumber: number;
}
/** Timeout for GitHub GraphQL API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
interface GraphQLReviewThreadComment {
databaseId: number;
}
interface GraphQLReviewThread {
id: string;
isResolved: boolean;
comments: {
nodes: GraphQLReviewThreadComment[];
};
}
interface GraphQLResponse {
data?: {
repository?: {
pullRequest?: {
reviewThreads?: {
nodes: GraphQLReviewThread[];
};
} | null;
};
};
errors?: Array<{ message: string }>;
}
interface ReviewThreadInfo {
isResolved: boolean;
threadId: string;
}
/**
* Fetch review thread resolved status and thread IDs using GitHub GraphQL API.
* Returns a map of comment ID (string) -> { isResolved, threadId }.
*/
async function fetchReviewThreadResolvedStatus(
projectPath: string,
owner: string,
repo: string,
prNumber: number
): Promise<Map<string, ReviewThreadInfo>> {
const resolvedMap = new Map<string, ReviewThreadInfo>();
const query = `
query GetPRReviewThreads(
$owner: String!
$repo: String!
$prNumber: Int!
) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
reviewThreads(first: 100) {
nodes {
id
isResolved
comments(first: 100) {
nodes {
databaseId
}
}
}
}
}
}
}`;
const variables = { owner, repo, prNumber };
const requestBody = JSON.stringify({ query, variables });
try {
const response = await new Promise<GraphQLResponse>((resolve, reject) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
const timeoutId = setTimeout(() => {
gh.kill();
reject(new Error('GitHub GraphQL API request timed out'));
}, GITHUB_API_TIMEOUT_MS);
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
clearTimeout(timeoutId);
if (code !== 0) {
return reject(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
resolve(JSON.parse(stdout));
} catch (e) {
reject(e);
}
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}
const threads = response.data?.repository?.pullRequest?.reviewThreads?.nodes ?? [];
for (const thread of threads) {
const info: ReviewThreadInfo = { isResolved: thread.isResolved, threadId: thread.id };
for (const comment of thread.comments.nodes) {
resolvedMap.set(String(comment.databaseId), info);
}
}
} catch (error) {
// Log but don't fail — resolved status is best-effort
logError(error, 'Failed to fetch PR review thread resolved status');
}
return resolvedMap;
}
/**
* Fetch all comments for a PR (both regular and inline review comments)
*/
async function fetchPRReviewComments(
projectPath: string,
owner: string,
repo: string,
prNumber: number
): Promise<PRReviewComment[]> {
const allComments: PRReviewComment[] = [];
// Fetch review thread resolved status in parallel with comment fetching
const resolvedStatusPromise = fetchReviewThreadResolvedStatus(projectPath, owner, repo, prNumber);
// 1. Fetch regular PR comments (issue-level comments)
try {
const { stdout: commentsOutput } = await execAsync(
`gh pr view ${prNumber} -R ${owner}/${repo} --json comments`,
{
cwd: projectPath,
env: execEnv,
}
);
const commentsData = JSON.parse(commentsOutput);
const regularComments = (commentsData.comments || []).map(
(c: {
id: string;
author: { login: string; avatarUrl?: string };
body: string;
createdAt: string;
updatedAt?: string;
}) => ({
id: String(c.id),
author: c.author?.login || 'unknown',
avatarUrl: c.author?.avatarUrl,
body: c.body,
createdAt: c.createdAt,
updatedAt: c.updatedAt,
isReviewComment: false,
isOutdated: false,
// Regular PR comments are not part of review threads, so not resolvable
isResolved: false,
})
);
allComments.push(...regularComments);
} catch (error) {
logError(error, 'Failed to fetch regular PR comments');
}
// 2. Fetch inline review comments (code-level comments with file/line info)
try {
const reviewsEndpoint = `repos/${owner}/${repo}/pulls/${prNumber}/comments`;
const { stdout: reviewsOutput } = await execAsync(`gh api ${reviewsEndpoint} --paginate`, {
cwd: projectPath,
env: execEnv,
});
const reviewsData = JSON.parse(reviewsOutput);
const reviewComments = (Array.isArray(reviewsData) ? reviewsData : []).map(
(c: {
id: number;
user: { login: string; avatar_url?: string };
body: string;
path: string;
line?: number;
original_line?: number;
created_at: string;
updated_at?: string;
diff_hunk?: string;
side?: string;
commit_id?: string;
position?: number | null;
}) => ({
id: String(c.id),
author: c.user?.login || 'unknown',
avatarUrl: c.user?.avatar_url,
body: c.body,
path: c.path,
line: c.line || c.original_line,
createdAt: c.created_at,
updatedAt: c.updated_at,
isReviewComment: true,
// A review comment is "outdated" if position is null (code has changed)
isOutdated: c.position === null && !c.line,
// isResolved will be filled in below from GraphQL data
isResolved: false,
diffHunk: c.diff_hunk,
side: c.side,
commitId: c.commit_id,
})
);
allComments.push(...reviewComments);
} catch (error) {
logError(error, 'Failed to fetch inline review comments');
}
// Wait for resolved status and apply to inline review comments
const resolvedMap = await resolvedStatusPromise;
if (resolvedMap.size > 0) {
for (const comment of allComments) {
if (comment.isReviewComment && resolvedMap.has(comment.id)) {
const info = resolvedMap.get(comment.id);
comment.isResolved = info?.isResolved ?? false;
comment.threadId = info?.threadId;
}
}
}
// Sort by createdAt descending (newest first)
allComments.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return allComments;
}
export function createListPRReviewCommentsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, prNumber } = req.body as ListPRReviewCommentsRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!prNumber || typeof prNumber !== 'number') {
res
.status(400)
.json({ success: false, error: 'prNumber is required and must be a number' });
return;
}
// Check if this is a GitHub repo and get owner/repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote || !remoteStatus.owner || !remoteStatus.repo) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const comments = await fetchPRReviewComments(
projectPath,
remoteStatus.owner,
remoteStatus.repo,
prNumber
);
res.json({
success: true,
comments,
totalCount: comments.length,
});
} catch (error) {
logError(error, 'Fetch PR review comments failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,151 @@
/**
* POST /resolve-pr-comment endpoint - Resolve or unresolve a GitHub PR review thread
*
* Uses the GitHub GraphQL API to resolve or unresolve a review thread
* identified by its GraphQL node ID (threadId).
*/
import { spawn } from 'child_process';
import type { Request, Response } from 'express';
import { execEnv, getErrorMessage, logError } from './common.js';
import { checkGitHubRemote } from './check-github-remote.js';
export interface ResolvePRCommentResult {
success: boolean;
isResolved?: boolean;
error?: string;
}
interface ResolvePRCommentRequest {
projectPath: string;
threadId: string;
resolve: boolean;
}
/** Timeout for GitHub GraphQL API requests in milliseconds */
const GITHUB_API_TIMEOUT_MS = 30000;
interface GraphQLMutationResponse {
data?: {
resolveReviewThread?: {
thread?: { isResolved: boolean; id: string } | null;
} | null;
unresolveReviewThread?: {
thread?: { isResolved: boolean; id: string } | null;
} | null;
};
errors?: Array<{ message: string }>;
}
/**
* Execute a GraphQL mutation to resolve or unresolve a review thread.
*/
async function executeReviewThreadMutation(
projectPath: string,
threadId: string,
resolve: boolean
): Promise<{ isResolved: boolean }> {
const mutationName = resolve ? 'resolveReviewThread' : 'unresolveReviewThread';
const mutation = `
mutation ${resolve ? 'ResolveThread' : 'UnresolveThread'}($threadId: ID!) {
${mutationName}(input: { threadId: $threadId }) {
thread {
id
isResolved
}
}
}`;
const variables = { threadId };
const requestBody = JSON.stringify({ query: mutation, variables });
const response = await new Promise<GraphQLMutationResponse>((res, rej) => {
const gh = spawn('gh', ['api', 'graphql', '--input', '-'], {
cwd: projectPath,
env: execEnv,
});
const timeoutId = setTimeout(() => {
gh.kill();
rej(new Error('GitHub GraphQL API request timed out'));
}, GITHUB_API_TIMEOUT_MS);
let stdout = '';
let stderr = '';
gh.stdout.on('data', (data: Buffer) => (stdout += data.toString()));
gh.stderr.on('data', (data: Buffer) => (stderr += data.toString()));
gh.on('close', (code) => {
clearTimeout(timeoutId);
if (code !== 0) {
return rej(new Error(`gh process exited with code ${code}: ${stderr}`));
}
try {
res(JSON.parse(stdout));
} catch (e) {
rej(e);
}
});
gh.stdin.write(requestBody);
gh.stdin.end();
});
if (response.errors && response.errors.length > 0) {
throw new Error(response.errors[0].message);
}
const threadData = resolve
? response.data?.resolveReviewThread?.thread
: response.data?.unresolveReviewThread?.thread;
if (!threadData) {
throw new Error('No thread data returned from GitHub API');
}
return { isResolved: threadData.isResolved };
}
export function createResolvePRCommentHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, threadId, resolve } = req.body as ResolvePRCommentRequest;
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath is required' });
return;
}
if (!threadId) {
res.status(400).json({ success: false, error: 'threadId is required' });
return;
}
if (typeof resolve !== 'boolean') {
res.status(400).json({ success: false, error: 'resolve must be a boolean' });
return;
}
// Check if this is a GitHub repo
const remoteStatus = await checkGitHubRemote(projectPath);
if (!remoteStatus.hasGitHubRemote) {
res.status(400).json({
success: false,
error: 'Project does not have a GitHub remote',
});
return;
}
const result = await executeReviewThreadMutation(projectPath, threadId, resolve);
res.json({
success: true,
isResolved: result.isResolved,
});
} catch (error) {
logError(error, 'Resolve PR comment failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -6,7 +6,6 @@ import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import type { IssueValidationEvent } from '@automaker/types';
import {
isValidationRunning,
getValidationStatus,
getRunningValidations,
abortValidation,
@@ -15,7 +14,6 @@ import {
logger,
} from './validation-common.js';
import {
readValidation,
getAllValidations,
getValidationWithFreshness,
deleteValidation,

View File

@@ -12,7 +12,7 @@ export function createProvidersHandler() {
// Get installation status from all providers
const statuses = await ProviderFactory.checkAllProviders();
const providers: Record<string, any> = {
const providers: Record<string, Record<string, unknown>> = {
anthropic: {
available: statuses.claude?.installed || false,
hasApiKey: !!process.env.ANTHROPIC_API_KEY,

View File

@@ -46,16 +46,14 @@ export function createUpdateGlobalHandler(settingsService: SettingsService) {
}
// Minimal debug logging to help diagnose accidental wipes.
const projectsLen = Array.isArray((updates as any).projects)
? (updates as any).projects.length
: undefined;
const trashedLen = Array.isArray((updates as any).trashedProjects)
? (updates as any).trashedProjects.length
const projectsLen = Array.isArray(updates.projects) ? updates.projects.length : undefined;
const trashedLen = Array.isArray(updates.trashedProjects)
? updates.trashedProjects.length
: undefined;
logger.info(
`[SERVER_SETTINGS_UPDATE] Request received: projects=${projectsLen ?? 'n/a'}, trashedProjects=${trashedLen ?? 'n/a'}, theme=${
(updates as any).theme ?? 'n/a'
}, localStorageMigrated=${(updates as any).localStorageMigrated ?? 'n/a'}`
updates.theme ?? 'n/a'
}, localStorageMigrated=${updates.localStorageMigrated ?? 'n/a'}`
);
// Get old settings to detect theme changes

View File

@@ -4,13 +4,9 @@
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
const execAsync = promisify(exec);
export function createAuthClaudeHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {

View File

@@ -4,13 +4,9 @@
import type { Request, Response } from 'express';
import { logError, getErrorMessage } from '../common.js';
import { exec } from 'child_process';
import { promisify } from 'util';
import * as fs from 'fs';
import * as path from 'path';
const execAsync = promisify(exec);
export function createAuthOpencodeHandler() {
return async (_req: Request, res: Response): Promise<void> => {
try {

View File

@@ -10,9 +10,6 @@ import type { Request, Response } from 'express';
import { CopilotProvider } from '../../../providers/copilot-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('CopilotModelsRoute');
// Singleton provider instance for caching
let providerInstance: CopilotProvider | null = null;

View File

@@ -14,9 +14,6 @@ import {
} from '../../../providers/opencode-provider.js';
import { getErrorMessage, logError } from '../common.js';
import type { ModelDefinition } from '@automaker/types';
import { createLogger } from '@automaker/utils';
const logger = createLogger('OpenCodeModelsRoute');
// Singleton provider instance for caching
let providerInstance: OpencodeProvider | null = null;

View File

@@ -110,6 +110,7 @@ export function createVerifyClaudeAuthHandler() {
let authenticated = false;
let errorMessage = '';
let receivedAnyContent = false;
let cleanupEnv: (() => void) | undefined;
// Create secure auth session
const sessionId = `claude-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -151,13 +152,13 @@ export function createVerifyClaudeAuthHandler() {
AuthSessionManager.createSession(sessionId, authMethod || 'api_key', apiKey, 'anthropic');
// Create temporary environment override for SDK call
const cleanupEnv = createTempEnvOverride(authEnv);
cleanupEnv = createTempEnvOverride(authEnv);
// Run a minimal query to verify authentication
const stream = query({
prompt: "Reply with only the word 'ok'",
options: {
model: 'claude-sonnet-4-20250514',
model: 'claude-sonnet-4-6',
maxTurns: 1,
allowedTools: [],
abortController,
@@ -194,8 +195,10 @@ export function createVerifyClaudeAuthHandler() {
}
// Check specifically for assistant messages with text content
if (msg.type === 'assistant' && (msg as any).message?.content) {
const content = (msg as any).message.content;
const msgRecord = msg as Record<string, unknown>;
const msgMessage = msgRecord.message as Record<string, unknown> | undefined;
if (msg.type === 'assistant' && msgMessage?.content) {
const content = msgMessage.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === 'text' && block.text) {
@@ -311,6 +314,8 @@ export function createVerifyClaudeAuthHandler() {
}
} finally {
clearTimeout(timeoutId);
// Restore process.env to its original state
cleanupEnv?.();
// Clean up the auth session
AuthSessionManager.destroySession(sessionId);
}

View File

@@ -5,7 +5,6 @@
import { randomBytes } from 'crypto';
import { createLogger } from '@automaker/utils';
import type { Request, Response, NextFunction } from 'express';
import { getTerminalService } from '../../services/terminal-service.js';
const logger = createLogger('Terminal');

View File

@@ -9,7 +9,6 @@ import {
generateToken,
addToken,
getTokenExpiryMs,
getErrorMessage,
} from '../common.js';
export function createAuthHandler() {

View File

@@ -2,59 +2,26 @@
* Common utilities for worktree routes
*/
import { createLogger } from '@automaker/utils';
import { spawnProcess } from '@automaker/platform';
import {
createLogger,
isValidBranchName,
isValidRemoteName,
MAX_BRANCH_NAME_LENGTH,
} from '@automaker/utils';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
// Re-export execGitCommand from the canonical shared module so any remaining
// consumers that import from this file continue to work.
export { execGitCommand } from '../../lib/git.js';
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
// ============================================================================
// Secure Command Execution
// ============================================================================
/**
* Execute git command with array arguments to prevent command injection.
* Uses spawnProcess from @automaker/platform for secure, cross-platform execution.
*
* @param args - Array of git command arguments (e.g., ['worktree', 'add', path])
* @param cwd - Working directory to execute the command in
* @returns Promise resolving to stdout output
* @throws Error with stderr message if command fails
*
* @example
* ```typescript
* // Safe: no injection possible
* await execGitCommand(['branch', '-D', branchName], projectPath);
*
* // Instead of unsafe:
* // await execAsync(`git branch -D ${branchName}`, { cwd });
* ```
*/
export async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
// spawnProcess returns { stdout, stderr, exitCode }
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
}
}
// ============================================================================
// Constants
// ============================================================================
/** Maximum allowed length for git branch names */
export const MAX_BRANCH_NAME_LENGTH = 250;
// Re-export git validation utilities from the canonical shared module so
// existing consumers that import from this file continue to work.
export { isValidBranchName, isValidRemoteName, MAX_BRANCH_NAME_LENGTH };
// ============================================================================
// Extended PATH configuration for Electron apps
@@ -98,19 +65,6 @@ export const execEnv = {
PATH: extendedPath,
};
// ============================================================================
// Validation utilities
// ============================================================================
/**
* Validate branch name to prevent command injection.
* Git branch names cannot contain: space, ~, ^, :, ?, *, [, \, or control chars.
* We also reject shell metacharacters for safety.
*/
export function isValidBranchName(name: string): boolean {
return /^[a-zA-Z0-9._\-/]+$/.test(name) && name.length < MAX_BRANCH_NAME_LENGTH;
}
/**
* Check if gh CLI is available on the system
*/

View File

@@ -51,9 +51,22 @@ import {
createDeleteInitScriptHandler,
createRunInitScriptHandler,
} from './routes/init-script.js';
import { createCommitLogHandler } from './routes/commit-log.js';
import { createDiscardChangesHandler } from './routes/discard-changes.js';
import { createListRemotesHandler } from './routes/list-remotes.js';
import { createAddRemoteHandler } from './routes/add-remote.js';
import { createStashPushHandler } from './routes/stash-push.js';
import { createStashListHandler } from './routes/stash-list.js';
import { createStashApplyHandler } from './routes/stash-apply.js';
import { createStashDropHandler } from './routes/stash-drop.js';
import { createCherryPickHandler } from './routes/cherry-pick.js';
import { createBranchCommitLogHandler } from './routes/branch-commit-log.js';
import { createGeneratePRDescriptionHandler } from './routes/generate-pr-description.js';
import { createRebaseHandler } from './routes/rebase.js';
import { createAbortOperationHandler } from './routes/abort-operation.js';
import { createContinueOperationHandler } from './routes/continue-operation.js';
import { createStageFilesHandler } from './routes/stage-files.js';
import { createCheckChangesHandler } from './routes/check-changes.js';
import type { SettingsService } from '../../services/settings-service.js';
export function createWorktreeRoutes(
@@ -71,9 +84,13 @@ export function createWorktreeRoutes(
'/merge',
validatePathParams('projectPath'),
requireValidProject,
createMergeHandler()
createMergeHandler(events)
);
router.post(
'/create',
validatePathParams('projectPath'),
createCreateHandler(events, settingsService)
);
router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
@@ -105,7 +122,13 @@ export function createWorktreeRoutes(
'/checkout-branch',
validatePathParams('worktreePath'),
requireValidWorktree,
createCheckoutBranchHandler()
createCheckoutBranchHandler(events)
);
router.post(
'/check-changes',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createCheckChangesHandler()
);
router.post(
'/list-branches',
@@ -113,7 +136,12 @@ export function createWorktreeRoutes(
requireValidWorktree,
createListBranchesHandler()
);
router.post('/switch-branch', requireValidWorktree, createSwitchBranchHandler());
router.post(
'/switch-branch',
validatePathParams('worktreePath'),
requireValidWorktree,
createSwitchBranchHandler(events)
);
router.post('/open-in-editor', validatePathParams('worktreePath'), createOpenInEditorHandler());
router.post(
'/open-in-terminal',
@@ -192,5 +220,95 @@ export function createWorktreeRoutes(
createAddRemoteHandler()
);
// Commit log route
router.post(
'/commit-log',
validatePathParams('worktreePath'),
requireValidWorktree,
createCommitLogHandler(events)
);
// Stash routes
router.post(
'/stash-push',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashPushHandler(events)
);
router.post(
'/stash-list',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashListHandler(events)
);
router.post(
'/stash-apply',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashApplyHandler(events)
);
router.post(
'/stash-drop',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createStashDropHandler(events)
);
// Cherry-pick route
router.post(
'/cherry-pick',
validatePathParams('worktreePath'),
requireValidWorktree,
createCherryPickHandler(events)
);
// Generate PR description route
router.post(
'/generate-pr-description',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createGeneratePRDescriptionHandler(settingsService)
);
// Branch commit log route (get commits from a specific branch)
router.post(
'/branch-commit-log',
validatePathParams('worktreePath'),
requireValidWorktree,
createBranchCommitLogHandler(events)
);
// Rebase route
router.post(
'/rebase',
validatePathParams('worktreePath'),
requireValidWorktree,
createRebaseHandler(events)
);
// Abort in-progress merge/rebase/cherry-pick
router.post(
'/abort-operation',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createAbortOperationHandler(events)
);
// Continue in-progress merge/rebase/cherry-pick after resolving conflicts
router.post(
'/continue-operation',
validatePathParams('worktreePath'),
requireGitRepoOnly,
createContinueOperationHandler(events)
);
// Stage/unstage files route
router.post(
'/stage-files',
validatePathParams('worktreePath', 'files[]'),
requireGitRepoOnly,
createStageFilesHandler()
);
return router;
}

View File

@@ -0,0 +1,117 @@
/**
* POST /abort-operation endpoint - Abort an in-progress merge, rebase, or cherry-pick
*
* Detects which operation (merge, rebase, or cherry-pick) is in progress
* and aborts it, returning the repository to a clean state.
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as fs from 'fs/promises';
import { getErrorMessage, logError, execAsync } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
/**
* Detect what type of conflict operation is currently in progress
*/
async function detectOperation(
worktreePath: string
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
try {
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
cwd: worktreePath,
});
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
await Promise.all([
fs
.access(path.join(gitDir, 'rebase-merge'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'rebase-apply'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'MERGE_HEAD'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
.then(() => true)
.catch(() => false),
]);
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
if (mergeHeadExists) return 'merge';
if (cherryPickHeadExists) return 'cherry-pick';
return null;
} catch {
return null;
}
}
export function createAbortOperationHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
const resolvedWorktreePath = path.resolve(worktreePath);
// Detect what operation is in progress
const operation = await detectOperation(resolvedWorktreePath);
if (!operation) {
res.status(400).json({
success: false,
error: 'No merge, rebase, or cherry-pick in progress',
});
return;
}
// Abort the operation
let abortCommand: string;
switch (operation) {
case 'merge':
abortCommand = 'git merge --abort';
break;
case 'rebase':
abortCommand = 'git rebase --abort';
break;
case 'cherry-pick':
abortCommand = 'git cherry-pick --abort';
break;
}
await execAsync(abortCommand, { cwd: resolvedWorktreePath });
// Emit event
events.emit('conflict:aborted', {
worktreePath: resolvedWorktreePath,
operation,
});
res.json({
success: true,
result: {
operation,
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} aborted successfully`,
},
});
} catch (error) {
logError(error, 'Abort operation failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,92 @@
/**
* POST /branch-commit-log endpoint - Get recent commit history for a specific branch
*
* Similar to commit-log but allows specifying a branch name to get commits from
* any branch, not just the currently checked out one. Useful for cherry-pick workflows
* where you need to browse commits from other branches.
*
* The handler only validates input, invokes the service, streams lifecycle events
* via the EventEmitter, and sends the final JSON response.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { getBranchCommitLog } from '../../../services/branch-commit-log-service.js';
import { isValidBranchName } from '@automaker/utils';
export function createBranchCommitLogHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const {
worktreePath,
branchName,
limit = 20,
} = req.body as {
worktreePath: string;
branchName?: string;
limit?: number;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Validate branchName before forwarding to execGitCommand.
// Reject values that start with '-', contain NUL, contain path-traversal
// sequences, or include characters outside the safe whitelist.
// An absent branchName is allowed (the service defaults it to HEAD).
if (branchName !== undefined && !isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: 'Invalid branchName: value contains unsafe characters or sequences',
});
return;
}
// Emit start event so the frontend can observe progress
events.emit('branchCommitLog:start', {
worktreePath,
branchName: branchName || 'HEAD',
limit,
});
// Delegate all Git work to the service
const result = await getBranchCommitLog(worktreePath, branchName, limit);
// Emit progress with the number of commits fetched
events.emit('branchCommitLog:progress', {
worktreePath,
branchName: result.branch,
commitsLoaded: result.total,
});
// Emit done event
events.emit('branchCommitLog:done', {
worktreePath,
branchName: result.branch,
total: result.total,
});
res.json({
success: true,
result,
});
} catch (error) {
// Emit error event so the frontend can react
events.emit('branchCommitLog:error', {
error: getErrorMessage(error),
});
logError(error, 'Get branch commit log failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -31,8 +31,8 @@ export async function getTrackedBranches(projectPath: string): Promise<TrackedBr
const content = (await secureFs.readFile(filePath, 'utf-8')) as string;
const data: BranchTrackingData = JSON.parse(content);
return data.branches || [];
} catch (error: any) {
if (error.code === 'ENOENT') {
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
logger.warn('Failed to read tracked branches:', error);

View File

@@ -0,0 +1,104 @@
/**
* POST /check-changes endpoint - Check for uncommitted changes in a worktree
*
* Returns a summary of staged, unstaged, and untracked files to help
* the user decide whether to stash before a branch operation.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
/**
* Parse `git status --porcelain` output into categorised file lists.
*
* Porcelain format gives two status characters per line:
* XY filename
* where X is the index (staged) status and Y is the worktree (unstaged) status.
*
* - '?' in both columns → untracked
* - Non-space/non-'?' in X → staged change
* - Non-space/non-'?' in Y (when not untracked) → unstaged change
*
* A file can appear in both staged and unstaged if it was partially staged.
*/
function parseStatusOutput(stdout: string): {
staged: string[];
unstaged: string[];
untracked: string[];
} {
const staged: string[] = [];
const unstaged: string[] = [];
const untracked: string[] = [];
const lines = stdout.trim().split('\n').filter(Boolean);
for (const line of lines) {
if (line.length < 3) continue;
const x = line[0]; // index status
const y = line[1]; // worktree status
// Handle renames which use " -> " separator
const rawPath = line.slice(3);
const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ')[1] : rawPath;
if (x === '?' && y === '?') {
untracked.push(filePath);
} else {
if (x !== ' ' && x !== '?') {
staged.push(filePath);
}
if (y !== ' ' && y !== '?') {
unstaged.push(filePath);
}
}
}
return { staged, unstaged, untracked };
}
export function createCheckChangesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Get porcelain status (includes staged, unstaged, and untracked files)
const stdout = await execGitCommand(['status', '--porcelain'], worktreePath);
const { staged, unstaged, untracked } = parseStatusOutput(stdout);
const hasChanges = staged.length > 0 || unstaged.length > 0 || untracked.length > 0;
// Deduplicate file paths across staged, unstaged, and untracked arrays
// to avoid double-counting partially staged files
const uniqueFilePaths = new Set([...staged, ...unstaged, ...untracked]);
res.json({
success: true,
result: {
hasChanges,
staged,
unstaged,
untracked,
totalFiles: uniqueFilePaths.size,
},
});
} catch (error) {
logError(error, 'Check changes failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,6 +1,14 @@
/**
* POST /checkout-branch endpoint - Create and checkout a new branch
*
* Supports automatic stash handling: when `stashChanges` is true, local changes
* are stashed before creating the branch and reapplied after. If the stash pop
* results in merge conflicts, returns a special response so the UI can create a
* conflict resolution task.
*
* Git business logic is delegated to checkout-branch-service.ts when stash
* handling is requested. Otherwise, falls back to the original simple flow.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts.
* Path validation (ALLOWED_ROOT_DIRECTORY) is handled by validatePathParams
@@ -10,14 +18,52 @@
import type { Request, Response } from 'express';
import path from 'path';
import { stat } from 'fs/promises';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import type { EventEmitter } from '../../../lib/events.js';
import { performCheckoutBranch } from '../../../services/checkout-branch-service.js';
import { createLogger } from '@automaker/utils';
export function createCheckoutBranchHandler() {
const logger = createLogger('CheckoutBranchRoute');
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
/**
* Fetch latest from all remotes (silently, with timeout).
* Non-fatal: fetch errors are logged and swallowed so the workflow continues.
*/
async function fetchRemotes(cwd: string): Promise<void> {
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
await execGitCommand(['fetch', '--all', '--quiet'], cwd, undefined, controller);
} catch (error) {
if (error instanceof Error && error.message === 'Process aborted') {
logger.warn(
`fetchRemotes timed out after ${FETCH_TIMEOUT_MS}ms - continuing without latest remote refs`
);
} else {
logger.warn(`fetchRemotes failed: ${getErrorMessage(error)} - continuing with local refs`);
}
// Non-fatal: continue with locally available refs
} finally {
clearTimeout(timerId);
}
}
export function createCheckoutBranchHandler(events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, branchName } = req.body as {
const { worktreePath, branchName, baseBranch, stashChanges, includeUntracked } = req.body as {
worktreePath: string;
branchName: string;
baseBranch?: string;
/** When true, stash local changes before checkout and reapply after */
stashChanges?: boolean;
/** When true, include untracked files in the stash (defaults to true) */
includeUntracked?: boolean;
};
if (!worktreePath) {
@@ -46,9 +92,17 @@ export function createCheckoutBranchHandler() {
return;
}
// Validate base branch if provided
if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
res.status(400).json({
success: false,
error:
'Invalid base branch name. Must contain only letters, numbers, dots, dashes, underscores, or slashes.',
});
return;
}
// Resolve and validate worktreePath to prevent traversal attacks.
// The validatePathParams middleware checks against ALLOWED_ROOT_DIRECTORY,
// but we also resolve the path and verify it exists as a directory.
const resolvedPath = path.resolve(worktreePath);
try {
const stats = await stat(resolvedPath);
@@ -67,7 +121,46 @@ export function createCheckoutBranchHandler() {
return;
}
// Get current branch for reference (using argument array to avoid shell injection)
// Use the service for stash-aware checkout
if (stashChanges) {
const result = await performCheckoutBranch(
resolvedPath,
branchName,
baseBranch,
{
stashChanges: true,
includeUntracked: includeUntracked ?? true,
},
events
);
if (!result.success) {
const statusCode = isBranchError(result.error) ? 400 : 500;
res.status(statusCode).json({
success: false,
error: result.error,
...(result.stashPopConflicts !== undefined && {
stashPopConflicts: result.stashPopConflicts,
}),
...(result.stashPopConflictMessage && {
stashPopConflictMessage: result.stashPopConflictMessage,
}),
});
return;
}
res.json({
success: true,
result: result.result,
});
return;
}
// Original simple flow (no stash handling)
// Fetch latest remote refs before creating the branch so that
// base branch validation works for remote references like "origin/main"
await fetchRemotes(resolvedPath);
const currentBranchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
resolvedPath
@@ -77,7 +170,6 @@ export function createCheckoutBranchHandler() {
// Check if branch already exists
try {
await execGitCommand(['rev-parse', '--verify', branchName], resolvedPath);
// Branch exists
res.status(400).json({
success: false,
error: `Branch '${branchName}' already exists`,
@@ -87,8 +179,25 @@ export function createCheckoutBranchHandler() {
// Branch doesn't exist, good to create
}
// Create and checkout the new branch (using argument array to avoid shell injection)
await execGitCommand(['checkout', '-b', branchName], resolvedPath);
// If baseBranch is provided, verify it exists before using it
if (baseBranch) {
try {
await execGitCommand(['rev-parse', '--verify', baseBranch], resolvedPath);
} catch {
res.status(400).json({
success: false,
error: `Base branch '${baseBranch}' does not exist`,
});
return;
}
}
// Create and checkout the new branch
const checkoutArgs = ['checkout', '-b', branchName];
if (baseBranch) {
checkoutArgs.push(baseBranch);
}
await execGitCommand(checkoutArgs, resolvedPath);
res.json({
success: true,
@@ -99,8 +208,22 @@ export function createCheckoutBranchHandler() {
},
});
} catch (error) {
events?.emit('switch:error', {
error: getErrorMessage(error),
});
logError(error, 'Checkout branch failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Determine whether an error message represents a client error (400).
* Stash failures are server-side errors and are intentionally excluded here
* so they are returned as HTTP 500 rather than HTTP 400.
*/
function isBranchError(error?: string): boolean {
if (!error) return false;
return error.includes('already exists') || error.includes('does not exist');
}

View File

@@ -0,0 +1,107 @@
/**
* POST /cherry-pick endpoint - Cherry-pick one or more commits into the current branch
*
* Applies commits from another branch onto the current branch.
* Supports single or multiple commit cherry-picks.
*
* Git business logic is delegated to cherry-pick-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
* The global event emitter is passed into the service so all lifecycle
* events (started, success, conflict, abort, verify-failed) are broadcast
* to WebSocket clients.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import path from 'path';
import { getErrorMessage, logError } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
import { verifyCommits, runCherryPick } from '../../../services/cherry-pick-service.js';
export function createCherryPickHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, commitHashes, options } = req.body as {
worktreePath: string;
commitHashes: string[];
options?: {
noCommit?: boolean;
};
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
// Normalize the path to prevent path traversal and ensure consistent paths
const resolvedWorktreePath = path.resolve(worktreePath);
if (!commitHashes || !Array.isArray(commitHashes) || commitHashes.length === 0) {
res.status(400).json({
success: false,
error: 'commitHashes array is required and must contain at least one commit hash',
});
return;
}
// Validate each commit hash format (should be hex string)
for (const hash of commitHashes) {
if (!/^[a-fA-F0-9]+$/.test(hash)) {
res.status(400).json({
success: false,
error: `Invalid commit hash format: "${hash}"`,
});
return;
}
}
// Verify each commit exists via the service; emits cherry-pick:verify-failed if any hash is missing
const invalidHash = await verifyCommits(resolvedWorktreePath, commitHashes, events);
if (invalidHash !== null) {
res.status(400).json({
success: false,
error: `Commit "${invalidHash}" does not exist`,
});
return;
}
// Execute the cherry-pick via the service.
// The service emits: cherry-pick:started, cherry-pick:success, cherry-pick:conflict,
// and cherry-pick:abort at the appropriate lifecycle points.
const result = await runCherryPick(resolvedWorktreePath, commitHashes, options, events);
if (result.success) {
res.json({
success: true,
result: {
cherryPicked: result.cherryPicked,
commitHashes: result.commitHashes,
branch: result.branch,
message: result.message,
},
});
} else if (result.hasConflicts) {
res.status(409).json({
success: false,
error: result.error,
hasConflicts: true,
aborted: result.aborted,
});
}
} catch (error) {
// Emit failure event for unexpected (non-conflict) errors
events.emit('cherry-pick:failure', {
error: getErrorMessage(error),
});
logError(error, 'Cherry-pick failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,72 @@
/**
* POST /commit-log endpoint - Get recent commit history for a worktree
*
* The handler only validates input, invokes the service, streams lifecycle
* events via the EventEmitter, and sends the final JSON response.
*
* Git business logic is delegated to commit-log-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { getCommitLog } from '../../../services/commit-log-service.js';
export function createCommitLogHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, limit = 20 } = req.body as {
worktreePath: string;
limit?: number;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Emit start event so the frontend can observe progress
events.emit('commitLog:start', {
worktreePath,
limit,
});
// Delegate all Git work to the service
const result = await getCommitLog(worktreePath, limit);
// Emit progress with the number of commits fetched
events.emit('commitLog:progress', {
worktreePath,
branch: result.branch,
commitsLoaded: result.total,
});
// Emit complete event
events.emit('commitLog:complete', {
worktreePath,
branch: result.branch,
total: result.total,
});
res.json({
success: true,
result,
});
} catch (error) {
// Emit error event so the frontend can react
events.emit('commitLog:error', {
error: getErrorMessage(error),
});
logError(error, 'Get commit log failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -6,18 +6,20 @@
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
export function createCommitHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, message } = req.body as {
const { worktreePath, message, files } = req.body as {
worktreePath: string;
message: string;
files?: string[];
};
if (!worktreePath || !message) {
@@ -44,11 +46,21 @@ export function createCommitHandler() {
return;
}
// Stage all changes
await execAsync('git add -A', { cwd: worktreePath });
// Stage changes - either specific files or all changes
if (files && files.length > 0) {
// Reset any previously staged changes first
await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath }).catch(() => {
// Ignore errors from reset (e.g., if nothing is staged)
});
// Stage only the selected files (args array avoids shell injection)
await execFileAsync('git', ['add', ...files], { cwd: worktreePath });
} else {
// Stage all changes (original behavior)
await execFileAsync('git', ['add', '-A'], { cwd: worktreePath });
}
// Create commit
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
// Create commit (pass message as arg to avoid shell injection)
await execFileAsync('git', ['commit', '-m', message], {
cwd: worktreePath,
});

View File

@@ -0,0 +1,151 @@
/**
* POST /continue-operation endpoint - Continue an in-progress merge, rebase, or cherry-pick
*
* After conflicts have been resolved, this endpoint continues the operation.
* For merge: performs git commit (merge is auto-committed after conflict resolution)
* For rebase: runs git rebase --continue
* For cherry-pick: runs git cherry-pick --continue
*/
import type { Request, Response } from 'express';
import path from 'path';
import * as fs from 'fs/promises';
import { getErrorMessage, logError, execAsync } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
/**
* Detect what type of conflict operation is currently in progress
*/
async function detectOperation(
worktreePath: string
): Promise<'merge' | 'rebase' | 'cherry-pick' | null> {
try {
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
cwd: worktreePath,
});
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
const [rebaseMergeExists, rebaseApplyExists, mergeHeadExists, cherryPickHeadExists] =
await Promise.all([
fs
.access(path.join(gitDir, 'rebase-merge'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'rebase-apply'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'MERGE_HEAD'))
.then(() => true)
.catch(() => false),
fs
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
.then(() => true)
.catch(() => false),
]);
if (rebaseMergeExists || rebaseApplyExists) return 'rebase';
if (mergeHeadExists) return 'merge';
if (cherryPickHeadExists) return 'cherry-pick';
return null;
} catch {
return null;
}
}
/**
* Check if there are still unmerged paths (unresolved conflicts)
*/
async function hasUnmergedPaths(worktreePath: string): Promise<boolean> {
try {
const { stdout: statusOutput } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
return statusOutput.split('\n').some((line) => /^(UU|AA|DD|AU|UA|DU|UD)/.test(line));
} catch {
return false;
}
}
export function createContinueOperationHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
const resolvedWorktreePath = path.resolve(worktreePath);
// Detect what operation is in progress
const operation = await detectOperation(resolvedWorktreePath);
if (!operation) {
res.status(400).json({
success: false,
error: 'No merge, rebase, or cherry-pick in progress',
});
return;
}
// Check for unresolved conflicts
if (await hasUnmergedPaths(resolvedWorktreePath)) {
res.status(409).json({
success: false,
error:
'There are still unresolved conflicts. Please resolve all conflicts before continuing.',
hasUnresolvedConflicts: true,
});
return;
}
// Stage all resolved files first
await execAsync('git add -A', { cwd: resolvedWorktreePath });
// Continue the operation
let continueCommand: string;
switch (operation) {
case 'merge':
// For merge, we need to commit after resolving conflicts
continueCommand = 'git commit --no-edit';
break;
case 'rebase':
continueCommand = 'git rebase --continue';
break;
case 'cherry-pick':
continueCommand = 'git cherry-pick --continue';
break;
}
await execAsync(continueCommand, {
cwd: resolvedWorktreePath,
env: { ...process.env, GIT_EDITOR: 'true' }, // Prevent editor from opening
});
// Emit event
events.emit('conflict:resolved', {
worktreePath: resolvedWorktreePath,
operation,
});
res.json({
success: true,
result: {
operation,
message: `${operation.charAt(0).toUpperCase() + operation.slice(1)} continued successfully`,
},
});
} catch (error) {
logError(error, 'Continue operation failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -9,19 +9,32 @@ import {
execAsync,
execEnv,
isValidBranchName,
isValidRemoteName,
isGhCliAvailable,
} from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { spawnProcess } from '@automaker/platform';
import { updateWorktreePRInfo } from '../../../lib/worktree-metadata.js';
import { createLogger } from '@automaker/utils';
import { validatePRState } from '@automaker/types';
import { resolvePrTarget } from '../../../services/pr-service.js';
const logger = createLogger('CreatePR');
export function createCreatePRHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, projectPath, commitMessage, prTitle, prBody, baseBranch, draft } =
req.body as {
const {
worktreePath,
projectPath,
commitMessage,
prTitle,
prBody,
baseBranch,
draft,
remote,
targetRemote,
} = req.body as {
worktreePath: string;
projectPath?: string;
commitMessage?: string;
@@ -29,6 +42,9 @@ export function createCreatePRHandler() {
prBody?: string;
baseBranch?: string;
draft?: boolean;
remote?: string;
/** Remote to create the PR against (e.g. upstream). If not specified, inferred from repo setup. */
targetRemote?: string;
};
if (!worktreePath) {
@@ -59,6 +75,52 @@ export function createCreatePRHandler() {
return;
}
// --- Input validation: run all validation before any git write operations ---
// Validate remote names before use to prevent command injection
if (remote !== undefined && !isValidRemoteName(remote)) {
res.status(400).json({
success: false,
error: 'Invalid remote name contains unsafe characters',
});
return;
}
if (targetRemote !== undefined && !isValidRemoteName(targetRemote)) {
res.status(400).json({
success: false,
error: 'Invalid target remote name contains unsafe characters',
});
return;
}
const pushRemote = remote || 'origin';
// Resolve repository URL, fork workflow, and target remote information.
// This is needed for both the existing PR check and PR creation.
// Resolve early so validation errors are caught before any writes.
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const prTarget = await resolvePrTarget({
worktreePath,
pushRemote,
targetRemote,
});
repoUrl = prTarget.repoUrl;
upstreamRepo = prTarget.upstreamRepo;
originOwner = prTarget.originOwner;
} catch (resolveErr) {
// resolvePrTarget throws for validation errors (unknown targetRemote, missing pushRemote)
res.status(400).json({
success: false,
error: getErrorMessage(resolveErr),
});
return;
}
// --- Validation complete — proceed with git operations ---
// Check for uncommitted changes
logger.debug(`Checking for uncommitted changes in: ${worktreePath}`);
const { stdout: status } = await execAsync('git status --porcelain', {
@@ -82,12 +144,9 @@ export function createCreatePRHandler() {
logger.debug(`Running: git add -A`);
await execAsync('git add -A', { cwd: worktreePath, env: execEnv });
// Create commit
// Create commit — pass message as a separate arg to avoid shell injection
logger.debug(`Running: git commit`);
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
cwd: worktreePath,
env: execEnv,
});
await execGitCommand(['commit', '-m', message], worktreePath);
// Get commit hash
const { stdout: hashOutput } = await execAsync('git rev-parse HEAD', {
@@ -110,20 +169,19 @@ export function createCreatePRHandler() {
}
}
// Push the branch to remote
// Push the branch to remote (use selected remote or default to 'origin')
// Uses array-based execGitCommand to avoid shell injection from pushRemote/branchName.
let pushError: string | null = null;
try {
await execAsync(`git push -u origin ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});
} catch (error: unknown) {
await execGitCommand(['push', pushRemote, branchName], worktreePath, execEnv);
} catch {
// If push fails, try with --set-upstream
try {
await execAsync(`git push --set-upstream origin ${branchName}`, {
cwd: worktreePath,
env: execEnv,
});
await execGitCommand(
['push', '--set-upstream', pushRemote, branchName],
worktreePath,
execEnv
);
} catch (error2: unknown) {
// Capture push error for reporting
const err = error2 as { stderr?: string; message?: string };
@@ -145,82 +203,11 @@ export function createCreatePRHandler() {
const base = baseBranch || 'main';
const title = prTitle || branchName;
const body = prBody || `Changes from branch ${branchName}`;
const draftFlag = draft ? '--draft' : '';
let prUrl: string | null = null;
let prError: string | null = null;
let browserUrl: string | null = null;
let ghCliAvailable = false;
// Get repository URL and detect fork workflow FIRST
// This is needed for both the existing PR check and PR creation
let repoUrl: string | null = null;
let upstreamRepo: string | null = null;
let originOwner: string | null = null;
try {
const { stdout: remotes } = await execAsync('git remote -v', {
cwd: worktreePath,
env: execEnv,
});
// Parse remotes to detect fork workflow and get repo URL
const lines = remotes.split(/\r?\n/); // Handle both Unix and Windows line endings
for (const line of lines) {
// Try multiple patterns to match different remote URL formats
// Pattern 1: git@github.com:owner/repo.git (fetch)
// Pattern 2: https://github.com/owner/repo.git (fetch)
// Pattern 3: https://github.com/owner/repo (fetch)
let match = line.match(/^(\w+)\s+.*[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\s+\(fetch\)/);
if (!match) {
// Try SSH format: git@github.com:owner/repo.git
match = line.match(/^(\w+)\s+git@[^:]+:([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/);
}
if (!match) {
// Try HTTPS format: https://github.com/owner/repo.git
match = line.match(
/^(\w+)\s+https?:\/\/[^/]+\/([^/]+)\/([^\s]+?)(?:\.git)?\s+\(fetch\)/
);
}
if (match) {
const [, remoteName, owner, repo] = match;
if (remoteName === 'upstream') {
upstreamRepo = `${owner}/${repo}`;
repoUrl = `https://github.com/${owner}/${repo}`;
} else if (remoteName === 'origin') {
originOwner = owner;
if (!repoUrl) {
repoUrl = `https://github.com/${owner}/${repo}`;
}
}
}
}
} catch (error) {
// Couldn't parse remotes - will try fallback
}
// Fallback: Try to get repo URL from git config if remote parsing failed
if (!repoUrl) {
try {
const { stdout: originUrl } = await execAsync('git config --get remote.origin.url', {
cwd: worktreePath,
env: execEnv,
});
const url = originUrl.trim();
// Parse URL to extract owner/repo
// Handle both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
let match = url.match(/[:/]([^/]+)\/([^/\s]+?)(?:\.git)?$/);
if (match) {
const [, owner, repo] = match;
originOwner = owner;
repoUrl = `https://github.com/${owner}/${repo}`;
}
} catch (error) {
// Failed to get repo URL from config
}
}
// Check if gh CLI is available (cross-platform)
ghCliAvailable = await isGhCliAvailable();
@@ -228,13 +215,16 @@ export function createCreatePRHandler() {
if (repoUrl) {
const encodedTitle = encodeURIComponent(title);
const encodedBody = encodeURIComponent(body);
// Encode base branch and head branch to handle special chars like # or %
const encodedBase = encodeURIComponent(base);
const encodedBranch = encodeURIComponent(branchName);
if (upstreamRepo && originOwner) {
// Fork workflow: PR to upstream from origin
browserUrl = `https://github.com/${upstreamRepo}/compare/${base}...${originOwner}:${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
// Fork workflow (or cross-remote PR): PR to target from push remote
browserUrl = `https://github.com/${upstreamRepo}/compare/${encodedBase}...${originOwner}:${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
} else {
// Regular repo
browserUrl = `${repoUrl}/compare/${base}...${branchName}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
browserUrl = `${repoUrl}/compare/${encodedBase}...${encodedBranch}?expand=1&title=${encodedTitle}&body=${encodedBody}`;
}
}
@@ -244,18 +234,40 @@ export function createCreatePRHandler() {
if (ghCliAvailable) {
// First, check if a PR already exists for this branch using gh pr list
// This is more reliable than gh pr view as it explicitly searches by branch name
// For forks, we need to use owner:branch format for the head parameter
// For forks/cross-remote, we need to use owner:branch format for the head parameter
const headRef = upstreamRepo && originOwner ? `${originOwner}:${branchName}` : branchName;
const repoArg = upstreamRepo ? ` --repo "${upstreamRepo}"` : '';
logger.debug(`Checking for existing PR for branch: ${branchName} (headRef: ${headRef})`);
try {
const listCmd = `gh pr list${repoArg} --head "${headRef}" --json number,title,url,state --limit 1`;
logger.debug(`Running: ${listCmd}`);
const { stdout: existingPrOutput } = await execAsync(listCmd, {
const listArgs = ['pr', 'list'];
if (upstreamRepo) {
listArgs.push('--repo', upstreamRepo);
}
listArgs.push(
'--head',
headRef,
'--json',
'number,title,url,state,createdAt',
'--limit',
'1'
);
logger.debug(`Running: gh ${listArgs.join(' ')}`);
const listResult = await spawnProcess({
command: 'gh',
args: listArgs,
cwd: worktreePath,
env: execEnv,
});
if (listResult.exitCode !== 0) {
logger.error(
`gh pr list failed with exit code ${listResult.exitCode}: ` +
`stderr=${listResult.stderr}, stdout=${listResult.stdout}`
);
throw new Error(
`gh pr list failed (exit code ${listResult.exitCode}): ${listResult.stderr || listResult.stdout}`
);
}
const existingPrOutput = listResult.stdout;
logger.debug(`gh pr list output: ${existingPrOutput}`);
const existingPrs = JSON.parse(existingPrOutput);
@@ -275,7 +287,7 @@ export function createCreatePRHandler() {
url: existingPr.url,
title: existingPr.title || title,
state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(),
createdAt: existingPr.createdAt || new Date().toISOString(),
});
logger.debug(
`Stored existing PR info for branch ${branchName}: PR #${existingPr.number}`
@@ -291,27 +303,35 @@ export function createCreatePRHandler() {
// Only create a new PR if one doesn't already exist
if (!prUrl) {
try {
// Build gh pr create command
let prCmd = `gh pr create --base "${base}"`;
// Build gh pr create args as an array to avoid shell injection on
// title/body (backticks, $, \ were unsafe with string interpolation)
const prArgs = ['pr', 'create', '--base', base];
// If this is a fork (has upstream remote), specify the repo and head
if (upstreamRepo && originOwner) {
// For forks: --repo specifies where to create PR, --head specifies source
prCmd += ` --repo "${upstreamRepo}" --head "${originOwner}:${branchName}"`;
prArgs.push('--repo', upstreamRepo, '--head', `${originOwner}:${branchName}`);
} else {
// Not a fork, just specify the head branch
prCmd += ` --head "${branchName}"`;
prArgs.push('--head', branchName);
}
prCmd += ` --title "${title.replace(/"/g, '\\"')}" --body "${body.replace(/"/g, '\\"')}" ${draftFlag}`;
prCmd = prCmd.trim();
prArgs.push('--title', title, '--body', body);
if (draft) prArgs.push('--draft');
logger.debug(`Creating PR with command: ${prCmd}`);
const { stdout: prOutput } = await execAsync(prCmd, {
logger.debug(`Creating PR with args: gh ${prArgs.join(' ')}`);
const prResult = await spawnProcess({
command: 'gh',
args: prArgs,
cwd: worktreePath,
env: execEnv,
});
prUrl = prOutput.trim();
if (prResult.exitCode !== 0) {
throw Object.assign(new Error(prResult.stderr || 'gh pr create failed'), {
stderr: prResult.stderr,
});
}
prUrl = prResult.stdout.trim();
logger.info(`PR created: ${prUrl}`);
// Extract PR number and store metadata for newly created PR
@@ -345,11 +365,26 @@ export function createCreatePRHandler() {
if (errorMessage.toLowerCase().includes('already exists')) {
logger.debug(`PR already exists error - trying to fetch existing PR`);
try {
const { stdout: viewOutput } = await execAsync(
`gh pr view --json number,title,url,state`,
{ cwd: worktreePath, env: execEnv }
// Build args as an array to avoid shell injection.
// When upstreamRepo is set (fork/cross-remote workflow) we must
// query the upstream repository so we find the correct PR.
const viewArgs = ['pr', 'view', '--json', 'number,title,url,state,createdAt'];
if (upstreamRepo) {
viewArgs.push('--repo', upstreamRepo);
}
logger.debug(`Running: gh ${viewArgs.join(' ')}`);
const viewResult = await spawnProcess({
command: 'gh',
args: viewArgs,
cwd: worktreePath,
env: execEnv,
});
if (viewResult.exitCode !== 0) {
throw new Error(
`gh pr view failed (exit code ${viewResult.exitCode}): ${viewResult.stderr || viewResult.stdout}`
);
const existingPr = JSON.parse(viewOutput);
}
const existingPr = JSON.parse(viewResult.stdout);
if (existingPr.url) {
prUrl = existingPr.url;
prNumber = existingPr.number;
@@ -361,7 +396,7 @@ export function createCreatePRHandler() {
url: existingPr.url,
title: existingPr.title || title,
state: validatePRState(existingPr.state),
createdAt: new Date().toISOString(),
createdAt: existingPr.createdAt || new Date().toISOString(),
});
logger.debug(`Fetched and stored existing PR: #${existingPr.number}`);
}

View File

@@ -13,6 +13,8 @@ import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
import type { EventEmitter } from '../../../lib/events.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { WorktreeService } from '../../../services/worktree-service.js';
import { isGitRepo } from '@automaker/git-utils';
import {
getErrorMessage,
@@ -20,14 +22,17 @@ import {
normalizePath,
ensureInitialCommit,
isValidBranchName,
execGitCommand,
} from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils';
import { runInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('Worktree');
/** Timeout for git fetch operations (30 seconds) */
const FETCH_TIMEOUT_MS = 30_000;
const execAsync = promisify(exec);
/**
@@ -81,13 +86,15 @@ async function findExistingWorktreeForBranch(
}
}
export function createCreateHandler(events: EventEmitter) {
export function createCreateHandler(events: EventEmitter, settingsService?: SettingsService) {
const worktreeService = new WorktreeService();
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
projectPath: string;
branchName: string;
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD)
baseBranch?: string; // Optional base branch to create from (defaults to current HEAD). Can be a remote branch like "origin/main".
};
if (!projectPath || !branchName) {
@@ -167,6 +174,25 @@ export function createCreateHandler(events: EventEmitter) {
// Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true });
// Fetch latest from all remotes before creating the worktree.
// This ensures remote refs are up-to-date for:
// - Remote base branches (e.g. "origin/main")
// - Existing remote branches being checked out as worktrees
// - Branch existence checks against fresh remote state
logger.info('Fetching from all remotes before creating worktree');
try {
const controller = new AbortController();
const timerId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
await execGitCommand(['fetch', '--all', '--quiet'], projectPath, undefined, controller);
} finally {
clearTimeout(timerId);
}
} catch (fetchErr) {
// Non-fatal: log but continue — refs might already be cached locally
logger.warn(`Failed to fetch from remotes: ${getErrorMessage(fetchErr)}`);
}
// Check if branch exists (using array arguments to prevent injection)
let branchExists = false;
try {
@@ -200,6 +226,20 @@ export function createCreateHandler(events: EventEmitter) {
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
// Copy configured files into the new worktree before responding
// This runs synchronously to ensure files are in place before any init script
try {
await worktreeService.copyConfiguredFiles(
projectPath,
absoluteWorktreePath,
settingsService,
events
);
} catch (copyErr) {
// Log but don't fail worktree creation files may be partially copied
logger.warn('Some configured files failed to copy to worktree:', copyErr);
}
// Respond immediately (non-blocking)
res.json({
success: true,

View File

@@ -6,7 +6,8 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isGitRepo } from '@automaker/git-utils';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import { execGitCommand } from '../../../lib/git.js';
import { createLogger } from '@automaker/utils';
const execAsync = promisify(exec);
@@ -51,7 +52,7 @@ export function createDeleteHandler() {
// Remove the worktree (using array arguments to prevent injection)
try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
} catch (error) {
} catch {
// Try with prune if remove fails
await execGitCommand(['worktree', 'prune'], projectPath);
}

View File

@@ -34,6 +34,7 @@ export function createDiffsHandler() {
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
...(result.mergeState ? { mergeState: result.mergeState } : {}),
});
return;
}
@@ -55,6 +56,7 @@ export function createDiffsHandler() {
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
...(result.mergeState ? { mergeState: result.mergeState } : {}),
});
} catch (innerError) {
// Worktree doesn't exist - fallback to main project path
@@ -71,6 +73,7 @@ export function createDiffsHandler() {
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
...(result.mergeState ? { mergeState: result.mergeState } : {}),
});
} catch (fallbackError) {
logError(fallbackError, 'Fallback to main project also failed');

View File

@@ -1,27 +1,63 @@
/**
* POST /discard-changes endpoint - Discard all uncommitted changes in a worktree
* POST /discard-changes endpoint - Discard uncommitted changes in a worktree
*
* This performs a destructive operation that:
* 1. Resets staged changes (git reset HEAD)
* 2. Discards modified tracked files (git checkout .)
* 3. Removes untracked files and directories (git clean -fd)
* Supports two modes:
* 1. Discard ALL changes (when no files array is provided)
* - Resets staged changes (git reset HEAD)
* - Discards modified tracked files (git checkout .)
* - Removes untracked files and directories (git clean -fd)
*
* 2. Discard SELECTED files (when files array is provided)
* - Unstages selected staged files (git reset HEAD -- <files>)
* - Reverts selected tracked file changes (git checkout -- <files>)
* - Removes selected untracked files (git clean -fd -- <files>)
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
import * as path from 'path';
import * as fs from 'fs';
import { getErrorMessage, logError } from '@automaker/utils';
import { execGitCommand } from '../../../lib/git.js';
const execAsync = promisify(exec);
/**
* Validate that a file path does not escape the worktree directory.
* Prevents path traversal attacks (e.g., ../../etc/passwd) and
* rejects symlinks inside the worktree that point outside of it.
*/
function validateFilePath(filePath: string, worktreePath: string): boolean {
// Resolve the full path relative to the worktree (lexical resolution)
const resolved = path.resolve(worktreePath, filePath);
const normalizedWorktree = path.resolve(worktreePath);
// First, perform lexical prefix check
const lexicalOk =
resolved.startsWith(normalizedWorktree + path.sep) || resolved === normalizedWorktree;
if (!lexicalOk) {
return false;
}
// Then, attempt symlink-aware validation using realpath.
// This catches symlinks inside the worktree that point outside of it.
try {
const realResolved = fs.realpathSync(resolved);
const realWorktree = fs.realpathSync(normalizedWorktree);
return realResolved.startsWith(realWorktree + path.sep) || realResolved === realWorktree;
} catch {
// If realpath fails (e.g., target doesn't exist yet for untracked files),
// fall back to the lexical startsWith check which already passed above.
return true;
}
}
export function createDiscardChangesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
const { worktreePath, files } = req.body as {
worktreePath: string;
files?: string[];
};
if (!worktreePath) {
@@ -33,9 +69,7 @@ export function createDiscardChangesHandler() {
}
// Check for uncommitted changes first
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
const status = await execGitCommand(['status', '--porcelain'], worktreePath);
if (!status.trim()) {
res.json({
@@ -48,39 +82,170 @@ export function createDiscardChangesHandler() {
return;
}
// Count the files that will be affected
const lines = status.trim().split('\n').filter(Boolean);
const fileCount = lines.length;
// Get branch name before discarding
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchOutput = await execGitCommand(
['rev-parse', '--abbrev-ref', 'HEAD'],
worktreePath
);
const branchName = branchOutput.trim();
// Discard all changes:
// 1. Reset any staged changes
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there's nothing staged
// Parse the status output to categorize files
// Git --porcelain format: XY PATH where X=index status, Y=worktree status
// Preserve the exact two-character XY status (no trim) to keep index vs worktree info
const statusLines = status.trim().split('\n').filter(Boolean);
const allFiles = statusLines.map((line) => {
const fileStatus = line.substring(0, 2);
const filePath = line.slice(3).trim();
return { status: fileStatus, path: filePath };
});
// Determine which files to discard
const isSelectiveDiscard = files && files.length > 0 && files.length < allFiles.length;
if (isSelectiveDiscard) {
// Selective discard: only discard the specified files
const filesToDiscard = new Set(files);
// Validate all requested file paths stay within the worktree
const invalidPaths = files.filter((f) => !validateFilePath(f, worktreePath));
if (invalidPaths.length > 0) {
res.status(400).json({
success: false,
error: `Invalid file paths detected (path traversal): ${invalidPaths.join(', ')}`,
});
return;
}
// Separate files into categories for proper git operations
const trackedModified: string[] = []; // Modified/deleted tracked files
const stagedFiles: string[] = []; // Files that are staged
const untrackedFiles: string[] = []; // Untracked files (?)
const warnings: string[] = [];
for (const file of allFiles) {
if (!filesToDiscard.has(file.path)) continue;
// file.status is the raw two-character XY git porcelain status (no trim)
// X = index/staging status, Y = worktree status
const xy = file.status.substring(0, 2);
const indexStatus = xy.charAt(0);
const workTreeStatus = xy.charAt(1);
if (indexStatus === '?' && workTreeStatus === '?') {
untrackedFiles.push(file.path);
} else if (indexStatus === 'A') {
// Staged-new file: must be reset (unstaged) then cleaned (deleted).
// Never pass to trackedModified — the file has no HEAD version to
// check out, so `git checkout --` would fail or do nothing.
stagedFiles.push(file.path);
untrackedFiles.push(file.path);
} else {
// Check if the file has staged changes (index status X)
if (indexStatus !== ' ' && indexStatus !== '?') {
stagedFiles.push(file.path);
}
// Check for working tree changes (worktree status Y): handles MM, MD, etc.
if (workTreeStatus !== ' ' && workTreeStatus !== '?') {
trackedModified.push(file.path);
}
}
}
// 1. Unstage selected staged files (using execFile to bypass shell)
if (stagedFiles.length > 0) {
try {
await execGitCommand(['reset', 'HEAD', '--', ...stagedFiles], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `Failed to unstage files: ${msg}`);
warnings.push(`Failed to unstage some files: ${msg}`);
}
}
// 2. Revert selected tracked file changes
if (trackedModified.length > 0) {
try {
await execGitCommand(['checkout', '--', ...trackedModified], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `Failed to revert tracked files: ${msg}`);
warnings.push(`Failed to revert some tracked files: ${msg}`);
}
}
// 3. Remove selected untracked files
if (untrackedFiles.length > 0) {
try {
await execGitCommand(['clean', '-fd', '--', ...untrackedFiles], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `Failed to clean untracked files: ${msg}`);
warnings.push(`Failed to remove some untracked files: ${msg}`);
}
}
const fileCount = files.length;
// Verify the remaining state
const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath);
const remainingCount = finalStatus.trim()
? finalStatus.trim().split('\n').filter(Boolean).length
: 0;
const actualDiscarded = allFiles.length - remainingCount;
let message =
actualDiscarded < fileCount
? `Discarded ${actualDiscarded} of ${fileCount} selected files, ${remainingCount} files remaining`
: `Discarded ${actualDiscarded} ${actualDiscarded === 1 ? 'file' : 'files'}`;
res.json({
success: true,
result: {
discarded: true,
filesDiscarded: actualDiscarded,
filesRemaining: remainingCount,
branch: branchName,
message,
...(warnings.length > 0 && { warnings }),
},
});
} else {
// Discard ALL changes (original behavior)
const fileCount = allFiles.length;
const warnings: string[] = [];
// 1. Reset any staged changes
try {
await execGitCommand(['reset', 'HEAD'], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `git reset HEAD failed: ${msg}`);
warnings.push(`Failed to unstage changes: ${msg}`);
}
// 2. Discard changes in tracked files
await execAsync('git checkout .', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no tracked changes
});
try {
await execGitCommand(['checkout', '.'], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `git checkout . failed: ${msg}`);
warnings.push(`Failed to revert tracked changes: ${msg}`);
}
// 3. Remove untracked files and directories
await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => {
// Ignore errors - might fail if there are no untracked files
});
try {
await execGitCommand(['clean', '-fd'], worktreePath);
} catch (error) {
const msg = getErrorMessage(error);
logError(error, `git clean -fd failed: ${msg}`);
warnings.push(`Failed to remove untracked files: ${msg}`);
}
// Verify all changes were discarded
const { stdout: finalStatus } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
const finalStatus = await execGitCommand(['status', '--porcelain'], worktreePath);
if (finalStatus.trim()) {
// Some changes couldn't be discarded (possibly ignored files or permission issues)
const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length;
res.json({
success: true,
@@ -90,6 +255,7 @@ export function createDiscardChangesHandler() {
filesRemaining: remainingCount,
branch: branchName,
message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`,
...(warnings.length > 0 && { warnings }),
},
});
} else {
@@ -101,9 +267,11 @@ export function createDiscardChangesHandler() {
filesRemaining: 0,
branch: branchName,
message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`,
...(warnings.length > 0 && { warnings }),
},
});
}
}
} catch (error) {
logError(error, 'Discard changes failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });

View File

@@ -213,10 +213,12 @@ export function createGenerateCommitMessageHandler(
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result if available (some providers return final text here)
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
}
}
const message = responseText.trim();

View File

@@ -0,0 +1,493 @@
/**
* POST /worktree/generate-pr-description endpoint - Generate an AI PR description from git diff
*
* Uses the configured model (via phaseModels.commitMessageModel) to generate a pull request
* title and description from the branch's changes compared to the base branch.
* Defaults to Claude Haiku for speed.
*/
import type { Request, Response } from 'express';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { existsSync } from 'fs';
import { join } from 'path';
import { createLogger } from '@automaker/utils';
import { isCursorModel, stripProviderPrefix } from '@automaker/types';
import { resolvePhaseModel } from '@automaker/model-resolver';
import { ProviderFactory } from '../../../providers/provider-factory.js';
import type { SettingsService } from '../../../services/settings-service.js';
import { getErrorMessage, logError } from '../common.js';
import { getPhaseModelWithOverrides } from '../../../lib/settings-helpers.js';
const logger = createLogger('GeneratePRDescription');
const execFileAsync = promisify(execFile);
/** Timeout for AI provider calls in milliseconds (30 seconds) */
const AI_TIMEOUT_MS = 30_000;
/** Max diff size to send to AI (characters) */
const MAX_DIFF_SIZE = 15_000;
const PR_DESCRIPTION_SYSTEM_PROMPT = `You are a pull request description generator. Your task is to create a clear, well-structured PR title and description based on the git diff and branch information provided.
IMPORTANT: Do NOT include any conversational text, explanations, or preamble. Do NOT say things like "I'll analyze..." or "Here is...". Output ONLY the structured format below and nothing else.
Output your response in EXACTLY this format (including the markers):
---TITLE---
<a concise PR title, 50-72 chars, imperative mood>
---BODY---
## Summary
<1-3 bullet points describing the key changes>
## Changes
<Detailed list of what was changed and why>
Rules:
- Your ENTIRE response must start with ---TITLE--- and contain nothing before it
- The title should be concise and descriptive (50-72 characters)
- Use imperative mood for the title (e.g., "Add dark mode toggle" not "Added dark mode toggle")
- The description should explain WHAT changed and WHY
- Group related changes together
- Use markdown formatting for the body
- Do NOT include the branch name in the title
- Focus on the user-facing impact when possible
- If there are breaking changes, mention them prominently
- The diff may include both committed changes and uncommitted working directory changes. Treat all changes as part of the PR since uncommitted changes will be committed when the PR is created
- Do NOT distinguish between committed and uncommitted changes in the output - describe all changes as a unified set of PR changes`;
/**
* Wraps an async generator with a timeout.
*/
async function* withTimeout<T>(
generator: AsyncIterable<T>,
timeoutMs: number
): AsyncGenerator<T, void, unknown> {
let timerId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timerId = setTimeout(
() => reject(new Error(`AI provider timed out after ${timeoutMs}ms`)),
timeoutMs
);
});
const iterator = generator[Symbol.asyncIterator]();
let done = false;
try {
while (!done) {
const result = await Promise.race([iterator.next(), timeoutPromise]).catch(async (err) => {
// Timeout (or other error) — attempt to gracefully close the source generator
await iterator.return?.();
throw err;
});
if (result.done) {
done = true;
} else {
yield result.value;
}
}
} finally {
clearTimeout(timerId);
}
}
interface GeneratePRDescriptionRequestBody {
worktreePath: string;
baseBranch?: string;
}
interface GeneratePRDescriptionSuccessResponse {
success: true;
title: string;
body: string;
}
interface GeneratePRDescriptionErrorResponse {
success: false;
error: string;
}
export function createGeneratePRDescriptionHandler(
settingsService?: SettingsService
): (req: Request, res: Response) => Promise<void> {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, baseBranch } = req.body as GeneratePRDescriptionRequestBody;
if (!worktreePath || typeof worktreePath !== 'string') {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'worktreePath is required and must be a string',
};
res.status(400).json(response);
return;
}
// Validate that the directory exists
if (!existsSync(worktreePath)) {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'worktreePath does not exist',
};
res.status(400).json(response);
return;
}
// Validate that it's a git repository
const gitPath = join(worktreePath, '.git');
if (!existsSync(gitPath)) {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'worktreePath is not a git repository',
};
res.status(400).json(response);
return;
}
// Validate baseBranch to allow only safe branch name characters
if (baseBranch !== undefined && !/^[\w.\-/]+$/.test(baseBranch)) {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'baseBranch contains invalid characters',
};
res.status(400).json(response);
return;
}
logger.info(`Generating PR description for worktree: ${worktreePath}`);
// Get current branch name
const { stdout: branchOutput } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd: worktreePath }
);
const branchName = branchOutput.trim();
// Determine the base branch for comparison
const base = baseBranch || 'main';
// Get the diff between current branch and base branch (committed changes)
// Track whether the diff method used only includes committed changes.
// `git diff base...HEAD` and `git diff origin/base...HEAD` only show committed changes,
// while the fallback methods (`git diff HEAD`, `git diff --cached + git diff`) already
// include uncommitted working directory changes.
let diff = '';
let diffIncludesUncommitted = false;
try {
// First, try to get diff against the base branch
const { stdout: branchDiff } = await execFileAsync('git', ['diff', `${base}...HEAD`], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5, // 5MB buffer
});
diff = branchDiff;
// git diff base...HEAD only shows committed changes
diffIncludesUncommitted = false;
} catch {
// If branch comparison fails (e.g., base branch doesn't exist locally),
// try fetching and comparing against remote base
try {
const { stdout: remoteDiff } = await execFileAsync(
'git',
['diff', `origin/${base}...HEAD`],
{
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
}
);
diff = remoteDiff;
// git diff origin/base...HEAD only shows committed changes
diffIncludesUncommitted = false;
} catch {
// Fall back to getting all uncommitted + committed changes
try {
const { stdout: allDiff } = await execFileAsync('git', ['diff', 'HEAD'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
diff = allDiff;
// git diff HEAD includes uncommitted changes
diffIncludesUncommitted = true;
} catch {
// Last resort: get staged + unstaged changes
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
diff = stagedDiff + unstagedDiff;
// These already include uncommitted changes
diffIncludesUncommitted = true;
}
}
}
// Check for uncommitted changes (staged + unstaged) to include in the description.
// When creating a PR, uncommitted changes will be auto-committed, so they should be
// reflected in the generated description. We only need to fetch uncommitted diffs
// when the primary diff method (base...HEAD) was used, since it only shows committed changes.
let hasUncommittedChanges = false;
try {
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain'], {
cwd: worktreePath,
});
hasUncommittedChanges = statusOutput.trim().length > 0;
if (hasUncommittedChanges && !diffIncludesUncommitted) {
logger.info('Uncommitted changes detected, including in PR description context');
let uncommittedDiff = '';
// Get staged changes
try {
const { stdout: stagedDiff } = await execFileAsync('git', ['diff', '--cached'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
if (stagedDiff.trim()) {
uncommittedDiff += stagedDiff;
}
} catch {
// Ignore staged diff errors
}
// Get unstaged changes (tracked files only)
try {
const { stdout: unstagedDiff } = await execFileAsync('git', ['diff'], {
cwd: worktreePath,
maxBuffer: 1024 * 1024 * 5,
});
if (unstagedDiff.trim()) {
uncommittedDiff += unstagedDiff;
}
} catch {
// Ignore unstaged diff errors
}
// Get list of untracked files for context
const untrackedFiles = statusOutput
.split('\n')
.filter((line) => line.startsWith('??'))
.map((line) => line.substring(3).trim());
if (untrackedFiles.length > 0) {
// Add a summary of untracked (new) files as context
uncommittedDiff += `\n# New untracked files:\n${untrackedFiles.map((f) => `# + ${f}`).join('\n')}\n`;
}
// Append uncommitted changes to the committed diff
if (uncommittedDiff.trim()) {
diff = diff + uncommittedDiff;
}
}
} catch {
// Ignore errors checking for uncommitted changes
}
// Also get the commit log for context
let commitLog = '';
try {
const { stdout: logOutput } = await execFileAsync(
'git',
['log', `${base}..HEAD`, '--oneline', '--no-decorate'],
{
cwd: worktreePath,
maxBuffer: 1024 * 1024,
}
);
commitLog = logOutput.trim();
} catch {
// If comparing against base fails, fall back to recent commits
try {
const { stdout: logOutput } = await execFileAsync(
'git',
['log', '--oneline', '-10', '--no-decorate'],
{
cwd: worktreePath,
maxBuffer: 1024 * 1024,
}
);
commitLog = logOutput.trim();
} catch {
// Ignore commit log errors
}
}
if (!diff.trim() && !commitLog.trim()) {
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'No changes found to generate a PR description from',
};
res.status(400).json(response);
return;
}
// Truncate diff if too long
const truncatedDiff =
diff.length > MAX_DIFF_SIZE
? diff.substring(0, MAX_DIFF_SIZE) + '\n\n[... diff truncated ...]'
: diff;
// Build the user prompt
let userPrompt = `Generate a pull request title and description for the following changes.\n\nBranch: ${branchName}\nBase Branch: ${base}\n`;
if (commitLog) {
userPrompt += `\nCommit History:\n${commitLog}\n`;
}
if (hasUncommittedChanges) {
userPrompt += `\nNote: This branch has uncommitted changes that will be included in the PR.\n`;
}
if (truncatedDiff) {
userPrompt += `\n\`\`\`diff\n${truncatedDiff}\n\`\`\``;
}
// Get model from phase settings with provider info
const {
phaseModel: phaseModelEntry,
provider: claudeCompatibleProvider,
credentials,
} = await getPhaseModelWithOverrides(
'commitMessageModel',
settingsService,
worktreePath,
'[GeneratePRDescription]'
);
const { model, thinkingLevel } = resolvePhaseModel(phaseModelEntry);
logger.info(
`Using model for PR description: ${model}`,
claudeCompatibleProvider ? `via provider: ${claudeCompatibleProvider.name}` : 'direct API'
);
// Get provider for the model type
const aiProvider = ProviderFactory.getProviderForModel(model);
const bareModel = stripProviderPrefix(model);
// For Cursor models, combine prompts
const effectivePrompt = isCursorModel(model)
? `${PR_DESCRIPTION_SYSTEM_PROMPT}\n\n${userPrompt}`
: userPrompt;
const effectiveSystemPrompt = isCursorModel(model) ? undefined : PR_DESCRIPTION_SYSTEM_PROMPT;
logger.info(`Using ${aiProvider.getName()} provider for model: ${model}`);
let responseText = '';
const stream = aiProvider.executeQuery({
prompt: effectivePrompt,
model: bareModel,
cwd: worktreePath,
systemPrompt: effectiveSystemPrompt,
maxTurns: 1,
allowedTools: [],
readOnly: true,
thinkingLevel,
claudeCompatibleProvider,
credentials,
});
// Wrap with timeout
for await (const msg of withTimeout(stream, AI_TIMEOUT_MS)) {
if (msg.type === 'assistant' && msg.message?.content) {
for (const block of msg.message.content) {
if (block.type === 'text' && block.text) {
responseText += block.text;
}
}
} else if (msg.type === 'result' && msg.subtype === 'success' && msg.result) {
// Use result text if longer than accumulated text (consistent with simpleQuery pattern)
if (msg.result.length > responseText.length) {
responseText = msg.result;
}
}
}
const fullResponse = responseText.trim();
if (!fullResponse || fullResponse.length === 0) {
logger.warn('Received empty response from model');
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: 'Failed to generate PR description - empty response',
};
res.status(500).json(response);
return;
}
// Parse the response to extract title and body.
// The model may include conversational preamble before the structured markers,
// so we search for the markers anywhere in the response, not just at the start.
let title = '';
let body = '';
const titleMatch = fullResponse.match(/---TITLE---\s*\n([\s\S]*?)(?=---BODY---|$)/);
const bodyMatch = fullResponse.match(/---BODY---\s*\n([\s\S]*?)$/);
if (titleMatch && bodyMatch) {
title = titleMatch[1].trim();
body = bodyMatch[1].trim();
} else {
// Fallback: try to extract meaningful content, skipping any conversational preamble.
// Common preamble patterns start with "I'll", "I will", "Here", "Let me", "Based on", etc.
const lines = fullResponse.split('\n').filter((line) => line.trim().length > 0);
// Skip lines that look like conversational preamble
let startIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// Check if this line looks like conversational AI preamble
if (
/^(I'll|I will|Here('s| is| are)|Let me|Based on|Looking at|Analyzing|Sure|OK|Okay|Of course)/i.test(
line
) ||
/^(The following|Below is|This (is|will)|After (analyzing|reviewing|looking))/i.test(
line
)
) {
startIndex = i + 1;
continue;
}
break;
}
// Use remaining lines after skipping preamble
const contentLines = lines.slice(startIndex);
if (contentLines.length > 0) {
title = contentLines[0].trim();
body = contentLines.slice(1).join('\n').trim();
} else {
// If all lines were filtered as preamble, use the original first non-empty line
title = lines[0]?.trim() || '';
body = lines.slice(1).join('\n').trim();
}
}
// Clean up title - remove any markdown headings, quotes, or marker artifacts
title = title
.replace(/^#+\s*/, '')
.replace(/^["']|["']$/g, '')
.replace(/^---\w+---\s*/, '');
logger.info(`Generated PR title: ${title.substring(0, 100)}...`);
const response: GeneratePRDescriptionSuccessResponse = {
success: true,
title,
body,
};
res.json(response);
} catch (error) {
logError(error, 'Generate PR description failed');
const response: GeneratePRDescriptionErrorResponse = {
success: false,
error: getErrorMessage(error),
};
res.status(500).json(response);
}
};
}

View File

@@ -6,11 +6,12 @@
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { exec, execFile } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logWorktreeError } from '../common.js';
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
interface BranchInfo {
name: string;
@@ -92,6 +93,9 @@ export function createListBranchesHandler() {
// Skip HEAD pointers like "origin/HEAD"
if (cleanName.includes('/HEAD')) return;
// Skip bare remote names without a branch (e.g. "origin" by itself)
if (!cleanName.includes('/')) return;
// Only add remote branches if a branch with the exact same name isn't already
// in the list. This avoids duplicates if a local branch is named like a remote one.
// Note: We intentionally include remote branches even when a local branch with the
@@ -126,17 +130,26 @@ export function createListBranchesHandler() {
let aheadCount = 0;
let behindCount = 0;
let hasRemoteBranch = false;
let trackingRemote: string | undefined;
try {
// First check if there's a remote tracking branch
const { stdout: upstreamOutput } = await execAsync(
`git rev-parse --abbrev-ref ${currentBranch}@{upstream}`,
const { stdout: upstreamOutput } = await execFileAsync(
'git',
['rev-parse', '--abbrev-ref', `${currentBranch}@{upstream}`],
{ cwd: worktreePath }
);
if (upstreamOutput.trim()) {
const upstreamRef = upstreamOutput.trim();
if (upstreamRef) {
hasRemoteBranch = true;
const { stdout: aheadBehindOutput } = await execAsync(
`git rev-list --left-right --count ${currentBranch}@{upstream}...HEAD`,
// Extract the remote name from the upstream ref (e.g. "origin/main" -> "origin")
const slashIndex = upstreamRef.indexOf('/');
if (slashIndex !== -1) {
trackingRemote = upstreamRef.slice(0, slashIndex);
}
const { stdout: aheadBehindOutput } = await execFileAsync(
'git',
['rev-list', '--left-right', '--count', `${currentBranch}@{upstream}...HEAD`],
{ cwd: worktreePath }
);
const [behind, ahead] = aheadBehindOutput.trim().split(/\s+/).map(Number);
@@ -147,8 +160,9 @@ export function createListBranchesHandler() {
// No upstream branch set - check if the branch exists on any remote
try {
// Check if there's a matching branch on origin (most common remote)
const { stdout: remoteBranchOutput } = await execAsync(
`git ls-remote --heads origin ${currentBranch}`,
const { stdout: remoteBranchOutput } = await execFileAsync(
'git',
['ls-remote', '--heads', 'origin', currentBranch],
{ cwd: worktreePath, timeout: 5000 }
);
hasRemoteBranch = remoteBranchOutput.trim().length > 0;
@@ -167,6 +181,7 @@ export function createListBranchesHandler() {
behindCount,
hasRemoteBranch,
hasAnyRemotes,
trackingRemote,
},
});
} catch (error) {

View File

@@ -58,6 +58,90 @@ interface WorktreeInfo {
hasChanges?: boolean;
changedFilesCount?: number;
pr?: WorktreePRInfo; // PR info if a PR has been created for this branch
/** Whether there are actual unresolved conflict files (conflictFiles.length > 0) */
hasConflicts?: boolean;
/** Type of git operation in progress (merge/rebase/cherry-pick), set independently of hasConflicts */
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */
conflictFiles?: string[];
}
/**
* Detect if a merge, rebase, or cherry-pick is in progress for a worktree.
* Checks for the presence of state files/directories that git creates
* during these operations.
*/
async function detectConflictState(worktreePath: string): Promise<{
hasConflicts: boolean;
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
conflictFiles?: string[];
}> {
try {
// Find the canonical .git directory for this worktree
const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', {
cwd: worktreePath,
timeout: 15000,
});
const gitDir = path.resolve(worktreePath, gitDirRaw.trim());
// Check for merge, rebase, and cherry-pick state files/directories
const [mergeHeadExists, rebaseMergeExists, rebaseApplyExists, cherryPickHeadExists] =
await Promise.all([
secureFs
.access(path.join(gitDir, 'MERGE_HEAD'))
.then(() => true)
.catch(() => false),
secureFs
.access(path.join(gitDir, 'rebase-merge'))
.then(() => true)
.catch(() => false),
secureFs
.access(path.join(gitDir, 'rebase-apply'))
.then(() => true)
.catch(() => false),
secureFs
.access(path.join(gitDir, 'CHERRY_PICK_HEAD'))
.then(() => true)
.catch(() => false),
]);
let conflictType: 'merge' | 'rebase' | 'cherry-pick' | undefined;
if (rebaseMergeExists || rebaseApplyExists) {
conflictType = 'rebase';
} else if (mergeHeadExists) {
conflictType = 'merge';
} else if (cherryPickHeadExists) {
conflictType = 'cherry-pick';
}
if (!conflictType) {
return { hasConflicts: false };
}
// Get list of conflicted files using machine-readable git status
let conflictFiles: string[] = [];
try {
const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', {
cwd: worktreePath,
timeout: 15000,
});
conflictFiles = statusOutput
.trim()
.split('\n')
.filter((f) => f.trim().length > 0);
} catch {
// Fall back to empty list if diff fails
}
return {
hasConflicts: conflictFiles.length > 0,
conflictType,
conflictFiles,
};
} catch {
// If anything fails, assume no conflicts
return { hasConflicts: false };
}
}
async function getCurrentBranch(cwd: string): Promise<string> {
@@ -373,7 +457,7 @@ export function createListHandler() {
// Read all worktree metadata to get PR info
const allMetadata = await readAllWorktreeMetadata(projectPath);
// If includeDetails is requested, fetch change status for each worktree
// If includeDetails is requested, fetch change status and conflict state for each worktree
if (includeDetails) {
for (const worktree of worktrees) {
try {
@@ -390,6 +474,21 @@ export function createListHandler() {
worktree.hasChanges = false;
worktree.changedFilesCount = 0;
}
// Detect merge/rebase/cherry-pick in progress
try {
const conflictState = await detectConflictState(worktree.path);
// Always propagate conflictType so callers know an operation is in progress,
// even when there are no unresolved conflict files yet.
if (conflictState.conflictType) {
worktree.conflictType = conflictState.conflictType;
}
// hasConflicts is true only when there are actual unresolved files
worktree.hasConflicts = conflictState.hasConflicts;
worktree.conflictFiles = conflictState.conflictFiles;
} catch {
// Ignore conflict detection errors
}
}
}

View File

@@ -8,15 +8,11 @@
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
import { createLogger } from '@automaker/utils';
import { getErrorMessage, logError } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
import { performMerge } from '../../../services/merge-service.js';
const execAsync = promisify(exec);
const logger = createLogger('Worktree');
export function createMergeHandler() {
export function createMergeHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, branchName, worktreePath, targetBranch, options } = req.body as {
@@ -24,7 +20,12 @@ export function createMergeHandler() {
branchName: string;
worktreePath: string;
targetBranch?: string; // Branch to merge into (defaults to 'main')
options?: { squash?: boolean; message?: string; deleteWorktreeAndBranch?: boolean };
options?: {
squash?: boolean;
message?: string;
deleteWorktreeAndBranch?: boolean;
remote?: string;
};
};
if (!projectPath || !branchName || !worktreePath) {
@@ -38,102 +39,41 @@ export function createMergeHandler() {
// Determine the target branch (default to 'main')
const mergeTo = targetBranch || 'main';
// Validate source branch exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, { cwd: projectPath });
} catch {
res.status(400).json({
success: false,
error: `Branch "${branchName}" does not exist`,
});
return;
}
// Delegate all merge logic to the service
const result = await performMerge(
projectPath,
branchName,
worktreePath,
mergeTo,
options,
events
);
// Validate target branch exists
try {
await execAsync(`git rev-parse --verify ${mergeTo}`, { cwd: projectPath });
} catch {
res.status(400).json({
success: false,
error: `Target branch "${mergeTo}" does not exist`,
});
return;
}
// Merge the feature branch into the target branch
const mergeCmd = options?.squash
? `git merge --squash ${branchName}`
: `git merge ${branchName} -m "${options?.message || `Merge ${branchName} into ${mergeTo}`}"`;
try {
await execAsync(mergeCmd, { cwd: projectPath });
} catch (mergeError: unknown) {
// Check if this is a merge conflict
const err = mergeError as { stdout?: string; stderr?: string; message?: string };
const output = `${err.stdout || ''} ${err.stderr || ''} ${err.message || ''}`;
const hasConflicts =
output.includes('CONFLICT') || output.includes('Automatic merge failed');
if (hasConflicts) {
if (!result.success) {
if (result.hasConflicts) {
// Return conflict-specific error message that frontend can detect
res.status(409).json({
success: false,
error: `Merge CONFLICT: Automatic merge of "${branchName}" into "${mergeTo}" failed. Please resolve conflicts manually.`,
error: result.error,
hasConflicts: true,
conflictFiles: result.conflictFiles,
});
return;
}
// Re-throw non-conflict errors to be handled by outer catch
throw mergeError;
}
// If squash merge, need to commit
if (options?.squash) {
await execAsync(`git commit -m "${options?.message || `Merge ${branchName} (squash)`}"`, {
cwd: projectPath,
// Non-conflict service errors (e.g. branch not found, invalid name)
res.status(400).json({
success: false,
error: result.error,
});
}
// Optionally delete the worktree and branch after merging
let worktreeDeleted = false;
let branchDeleted = false;
if (options?.deleteWorktreeAndBranch) {
// Remove the worktree
try {
await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
worktreeDeleted = true;
} catch {
// Try with prune if remove fails
try {
await execGitCommand(['worktree', 'prune'], projectPath);
worktreeDeleted = true;
} catch {
logger.warn(`Failed to remove worktree: ${worktreePath}`);
}
}
// Delete the branch (but not main/master)
if (branchName !== 'main' && branchName !== 'master') {
if (!isValidBranchName(branchName)) {
logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
} else {
try {
await execGitCommand(['branch', '-D', branchName], projectPath);
branchDeleted = true;
} catch {
logger.warn(`Failed to delete branch: ${branchName}`);
}
}
}
return;
}
res.json({
success: true,
mergedBranch: branchName,
targetBranch: mergeTo,
deleted: options?.deleteWorktreeAndBranch ? { worktreeDeleted, branchDeleted } : undefined,
mergedBranch: result.mergedBranch,
targetBranch: result.targetBranch,
deleted: result.deleted,
});
} catch (error) {
logError(error, 'Merge worktree failed');

View File

@@ -1,22 +1,33 @@
/**
* POST /pull endpoint - Pull latest changes for a worktree/branch
*
* Enhanced pull flow with stash management and conflict detection:
* 1. Checks for uncommitted local changes (staged and unstaged)
* 2. If local changes exist AND stashIfNeeded is true, automatically stashes them
* 3. Performs the git pull
* 4. If changes were stashed, attempts to reapply via git stash pop
* 5. Detects merge conflicts from both pull and stash reapplication
* 6. Returns structured conflict information for AI-assisted resolution
*
* Git business logic is delegated to pull-service.ts.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
const execAsync = promisify(exec);
import { performPull } from '../../../services/pull-service.js';
import type { PullResult } from '../../../services/pull-service.js';
export function createPullHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
const { worktreePath, remote, stashIfNeeded } = req.body as {
worktreePath: string;
remote?: string;
/** When true, automatically stash local changes before pulling and reapply after */
stashIfNeeded?: boolean;
};
if (!worktreePath) {
@@ -27,67 +38,69 @@ export function createPullHandler() {
return;
}
// Get current branch name
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const branchName = branchOutput.trim();
// Execute the pull via the service
const result = await performPull(worktreePath, { remote, stashIfNeeded });
// Fetch latest from remote
await execAsync('git fetch origin', { cwd: worktreePath });
// Check if there are local changes that would be overwritten
const { stdout: status } = await execAsync('git status --porcelain', {
cwd: worktreePath,
});
const hasLocalChanges = status.trim().length > 0;
if (hasLocalChanges) {
res.status(400).json({
success: false,
error: 'You have local changes. Please commit them before pulling.',
});
return;
}
// Pull latest changes
try {
const { stdout: pullOutput } = await execAsync(`git pull origin ${branchName}`, {
cwd: worktreePath,
});
// Check if we pulled any changes
const alreadyUpToDate = pullOutput.includes('Already up to date');
res.json({
success: true,
result: {
branch: branchName,
pulled: !alreadyUpToDate,
message: alreadyUpToDate ? 'Already up to date' : 'Pulled latest changes',
},
});
} catch (pullError: unknown) {
const err = pullError as { stderr?: string; message?: string };
const errorMsg = err.stderr || err.message || 'Pull failed';
// Check for common errors
if (errorMsg.includes('no tracking information')) {
res.status(400).json({
success: false,
error: `Branch '${branchName}' has no upstream branch. Push it first or set upstream with: git branch --set-upstream-to=origin/${branchName}`,
});
return;
}
res.status(500).json({
success: false,
error: errorMsg,
});
}
// Map service result to HTTP response
mapResultToResponse(res, result);
} catch (error) {
logError(error, 'Pull failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Map a PullResult from the service to the appropriate HTTP response.
*
* - Successful results (including local-changes-detected info) → 200
* - Validation/state errors (detached HEAD, no upstream) → 400
* - Operational errors (fetch/stash/pull failures) → 500
*/
function mapResultToResponse(res: Response, result: PullResult): void {
if (!result.success && result.error) {
// Determine the appropriate HTTP status for errors
const statusCode = isClientError(result.error) ? 400 : 500;
res.status(statusCode).json({
success: false,
error: result.error,
...(result.stashRecoveryFailed && { stashRecoveryFailed: true }),
});
return;
}
// Success case (includes partial success like local changes detected, conflicts, etc.)
res.json({
success: true,
result: {
branch: result.branch,
pulled: result.pulled,
hasLocalChanges: result.hasLocalChanges,
localChangedFiles: result.localChangedFiles,
hasConflicts: result.hasConflicts,
conflictSource: result.conflictSource,
conflictFiles: result.conflictFiles,
stashed: result.stashed,
stashRestored: result.stashRestored,
message: result.message,
isMerge: result.isMerge,
isFastForward: result.isFastForward,
mergeAffectedFiles: result.mergeAffectedFiles,
},
});
}
/**
* Determine whether an error message represents a client error (400)
* vs a server error (500).
*
* Client errors are validation issues or invalid git state that the user
* needs to resolve (e.g. detached HEAD, no upstream, no tracking info).
*/
function isClientError(errorMessage: string): boolean {
return (
errorMessage.includes('detached HEAD') ||
errorMessage.includes('has no upstream branch') ||
errorMessage.includes('no tracking information')
);
}

View File

@@ -0,0 +1,135 @@
/**
* POST /rebase endpoint - Rebase the current branch onto a target branch
*
* Rebases the current worktree branch onto a specified target branch
* (e.g., origin/main) for a linear history. Detects conflicts and
* returns structured conflict information for AI-assisted resolution.
*
* Git business logic is delegated to rebase-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import path from 'path';
import { getErrorMessage, logError, isValidBranchName, isValidRemoteName } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
import { runRebase } from '../../../services/rebase-service.js';
export function createRebaseHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, ontoBranch, remote } = req.body as {
worktreePath: string;
/** The branch/ref to rebase onto (e.g., 'origin/main', 'main') */
ontoBranch: string;
/** Remote name to fetch from before rebasing (defaults to 'origin') */
remote?: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath is required',
});
return;
}
if (!ontoBranch) {
res.status(400).json({
success: false,
error: 'ontoBranch is required',
});
return;
}
// Normalize the path to prevent path traversal and ensure consistent paths
const resolvedWorktreePath = path.resolve(worktreePath);
// Validate the branch name (allow remote refs like origin/main)
if (!isValidBranchName(ontoBranch)) {
res.status(400).json({
success: false,
error: `Invalid branch name: "${ontoBranch}"`,
});
return;
}
// Validate optional remote name to reject unsafe characters at the route layer
if (remote !== undefined && !isValidRemoteName(remote)) {
res.status(400).json({
success: false,
error: `Invalid remote name: "${remote}"`,
});
return;
}
// Emit started event
events.emit('rebase:started', {
worktreePath: resolvedWorktreePath,
ontoBranch,
});
// Execute the rebase via the service
const result = await runRebase(resolvedWorktreePath, ontoBranch, { remote });
if (result.success) {
// Emit success event
events.emit('rebase:success', {
worktreePath: resolvedWorktreePath,
branch: result.branch,
ontoBranch: result.ontoBranch,
});
res.json({
success: true,
result: {
branch: result.branch,
ontoBranch: result.ontoBranch,
message: result.message,
},
});
} else if (result.hasConflicts) {
// Emit conflict event
events.emit('rebase:conflict', {
worktreePath: resolvedWorktreePath,
ontoBranch,
conflictFiles: result.conflictFiles,
aborted: result.aborted,
});
res.status(409).json({
success: false,
error: result.error,
hasConflicts: true,
conflictFiles: result.conflictFiles,
aborted: result.aborted,
});
} else {
// Emit failure event for non-conflict failures
events.emit('rebase:failure', {
worktreePath: resolvedWorktreePath,
branch: result.branch,
ontoBranch: result.ontoBranch,
error: result.error,
});
res.status(500).json({
success: false,
error: result.error ?? 'Rebase failed',
hasConflicts: false,
});
}
} catch (error) {
// Emit failure event
events.emit('rebase:failure', {
error: getErrorMessage(error),
});
logError(error, 'Rebase failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,74 @@
/**
* POST /stage-files endpoint - Stage or unstage files in a worktree
*
* Supports two operations:
* 1. Stage files: `git add <files>` (adds files to the staging area)
* 2. Unstage files: `git reset HEAD -- <files>` (removes files from staging area)
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import { getErrorMessage, logError } from '../common.js';
import { stageFiles, StageFilesValidationError } from '../../../services/stage-files-service.js';
export function createStageFilesHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, files, operation } = req.body as {
worktreePath: string;
files: string[];
operation: 'stage' | 'unstage';
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
if (!Array.isArray(files) || files.length === 0) {
res.status(400).json({
success: false,
error: 'files array required and must not be empty',
});
return;
}
for (const file of files) {
if (typeof file !== 'string' || file.trim() === '') {
res.status(400).json({
success: false,
error: 'Each element of files must be a non-empty string',
});
return;
}
}
if (operation !== 'stage' && operation !== 'unstage') {
res.status(400).json({
success: false,
error: 'operation must be "stage" or "unstage"',
});
return;
}
const result = await stageFiles(worktreePath, files, operation);
res.json({
success: true,
result,
});
} catch (error) {
if (error instanceof StageFilesValidationError) {
res.status(400).json({ success: false, error: error.message });
return;
}
logError(error, `${(req.body as { operation?: string })?.operation ?? 'stage'} files failed`);
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,78 @@
/**
* POST /stash-apply endpoint - Apply or pop a stash in a worktree
*
* Applies a specific stash entry to the working directory.
* Can either "apply" (keep stash) or "pop" (remove stash after applying).
*
* All git operations and conflict detection are delegated to StashService.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { applyOrPop } from '../../../services/stash-service.js';
export function createStashApplyHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, stashIndex, pop } = req.body as {
worktreePath: string;
stashIndex: number;
pop?: boolean;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
if (stashIndex === undefined || stashIndex === null) {
res.status(400).json({
success: false,
error: 'stashIndex required',
});
return;
}
const idx = typeof stashIndex === 'string' ? Number(stashIndex) : stashIndex;
if (!Number.isInteger(idx) || idx < 0) {
res.status(400).json({
success: false,
error: 'stashIndex must be a non-negative integer',
});
return;
}
// Delegate all stash apply/pop logic to the service
const result = await applyOrPop(worktreePath, idx, { pop }, events);
if (!result.success) {
// applyOrPop already logs the error internally via logError — no need to double-log here
res.status(500).json({ success: false, error: result.error });
return;
}
res.json({
success: true,
result: {
applied: result.applied,
hasConflicts: result.hasConflicts,
conflictFiles: result.conflictFiles,
operation: result.operation,
stashIndex: result.stashIndex,
message: result.message,
},
});
} catch (error) {
logError(error, 'Stash apply failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,83 @@
/**
* POST /stash-drop endpoint - Drop (delete) a stash entry
*
* The handler only validates input, invokes the service, streams lifecycle
* events via the EventEmitter, and sends the final JSON response.
*
* Git business logic is delegated to stash-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { dropStash } from '../../../services/stash-service.js';
export function createStashDropHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, stashIndex } = req.body as {
worktreePath: string;
stashIndex: number;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
if (!Number.isInteger(stashIndex) || stashIndex < 0) {
res.status(400).json({
success: false,
error: 'stashIndex required',
});
return;
}
// Emit start event so the frontend can observe progress
events.emit('stash:start', {
worktreePath,
stashIndex,
stashRef: `stash@{${stashIndex}}`,
operation: 'drop',
});
// Delegate all Git work to the service
const result = await dropStash(worktreePath, stashIndex);
// Emit success event
events.emit('stash:success', {
worktreePath,
stashIndex,
operation: 'drop',
dropped: result.dropped,
});
res.json({
success: true,
result: {
dropped: result.dropped,
stashIndex: result.stashIndex,
message: result.message,
},
});
} catch (error) {
// Emit error event so the frontend can react
events.emit('stash:failure', {
worktreePath: req.body?.worktreePath,
stashIndex: req.body?.stashIndex,
operation: 'drop',
error: getErrorMessage(error),
});
logError(error, 'Stash drop failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,76 @@
/**
* POST /stash-list endpoint - List all stashes in a worktree
*
* The handler only validates input, invokes the service, streams lifecycle
* events via the EventEmitter, and sends the final JSON response.
*
* Git business logic is delegated to stash-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { listStash } from '../../../services/stash-service.js';
export function createStashListHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath } = req.body as {
worktreePath: string;
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Emit start event so the frontend can observe progress
events.emit('stash:start', {
worktreePath,
operation: 'list',
});
// Delegate all Git work to the service
const result = await listStash(worktreePath);
// Emit progress with stash count
events.emit('stash:progress', {
worktreePath,
operation: 'list',
total: result.total,
});
// Emit success event
events.emit('stash:success', {
worktreePath,
operation: 'list',
total: result.total,
});
res.json({
success: true,
result: {
stashes: result.stashes,
total: result.total,
},
});
} catch (error) {
// Emit error event so the frontend can react
events.emit('stash:failure', {
worktreePath: req.body?.worktreePath,
operation: 'list',
error: getErrorMessage(error),
});
logError(error, 'Stash list failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -0,0 +1,81 @@
/**
* POST /stash-push endpoint - Stash changes in a worktree
*
* The handler only validates input, invokes the service, streams lifecycle
* events via the EventEmitter, and sends the final JSON response.
*
* Git business logic is delegated to stash-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo) is handled by
* the requireGitRepoOnly middleware in index.ts
*/
import type { Request, Response } from 'express';
import type { EventEmitter } from '../../../lib/events.js';
import { getErrorMessage, logError } from '../common.js';
import { pushStash } from '../../../services/stash-service.js';
export function createStashPushHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, message, files } = req.body as {
worktreePath: string;
message?: string;
files?: string[];
};
if (!worktreePath) {
res.status(400).json({
success: false,
error: 'worktreePath required',
});
return;
}
// Emit start event so the frontend can observe progress
events.emit('stash:start', {
worktreePath,
operation: 'push',
});
// Delegate all Git work to the service
const result = await pushStash(worktreePath, { message, files });
// Emit progress with stash result
events.emit('stash:progress', {
worktreePath,
operation: 'push',
stashed: result.stashed,
branch: result.branch,
});
// Emit success event
events.emit('stash:success', {
worktreePath,
operation: 'push',
stashed: result.stashed,
branch: result.branch,
});
res.json({
success: true,
result: {
stashed: result.stashed,
branch: result.branch,
message: result.message,
},
});
} catch (error) {
// Emit error event so the frontend can react
events.emit('stash:failure', {
worktreePath: req.body?.worktreePath,
operation: 'push',
error: getErrorMessage(error),
});
logError(error, 'Stash push failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}

View File

@@ -1,67 +1,29 @@
/**
* POST /switch-branch endpoint - Switch to an existing branch
*
* Simple branch switching.
* If there are uncommitted changes, the switch will fail and
* the user should commit first.
* Handles branch switching with automatic stash/reapply of local changes.
* If there are uncommitted changes, they are stashed before switching and
* reapplied after. If the stash pop results in merge conflicts, returns
* a special response code so the UI can create a conflict resolution task.
*
* For remote branches (e.g., "origin/feature"), automatically creates a
* local tracking branch and checks it out.
*
* Also fetches the latest remote refs before switching to ensure accurate branch detection.
*
* Git business logic is delegated to worktree-branch-service.ts.
* Events are emitted at key lifecycle points for WebSocket subscribers.
*
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
* the requireValidWorktree middleware in index.ts
*/
import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getErrorMessage, logError } from '../common.js';
import { getErrorMessage, logError, isValidBranchName } from '../common.js';
import type { EventEmitter } from '../../../lib/events.js';
import { performSwitchBranch } from '../../../services/worktree-branch-service.js';
const execAsync = promisify(exec);
function isUntrackedLine(line: string): boolean {
return line.startsWith('?? ');
}
function isExcludedWorktreeLine(line: string): boolean {
return line.includes('.worktrees/') || line.endsWith('.worktrees');
}
function isBlockingChangeLine(line: string): boolean {
if (!line.trim()) return false;
if (isExcludedWorktreeLine(line)) return false;
if (isUntrackedLine(line)) return false;
return true;
}
/**
* Check if there are uncommitted changes in the working directory
* Excludes .worktrees/ directory which is created by automaker
*/
async function hasUncommittedChanges(cwd: string): Promise<boolean> {
try {
const { stdout } = await execAsync('git status --porcelain', { cwd });
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
return lines.length > 0;
} catch {
return false;
}
}
/**
* Get a summary of uncommitted changes for user feedback
* Excludes .worktrees/ directory
*/
async function getChangesSummary(cwd: string): Promise<string> {
try {
const { stdout } = await execAsync('git status --short', { cwd });
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
if (lines.length === 0) return '';
if (lines.length <= 5) return lines.join(', ');
return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`;
} catch {
return 'unknown changes';
}
}
export function createSwitchBranchHandler() {
export function createSwitchBranchHandler(events?: EventEmitter) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { worktreePath, branchName } = req.body as {
@@ -85,62 +47,58 @@ export function createSwitchBranchHandler() {
return;
}
// Get current branch
const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', {
cwd: worktreePath,
});
const previousBranch = currentBranchOutput.trim();
if (previousBranch === branchName) {
res.json({
success: true,
result: {
previousBranch,
currentBranch: branchName,
message: `Already on branch '${branchName}'`,
},
});
return;
}
// Check if branch exists
try {
await execAsync(`git rev-parse --verify ${branchName}`, {
cwd: worktreePath,
});
} catch {
// Validate branch name using shared allowlist to prevent Git option injection
if (!isValidBranchName(branchName)) {
res.status(400).json({
success: false,
error: `Branch '${branchName}' does not exist`,
error: 'Invalid branch name',
});
return;
}
// Check for uncommitted changes
if (await hasUncommittedChanges(worktreePath)) {
const summary = await getChangesSummary(worktreePath);
res.status(400).json({
// Execute the branch switch via the service
const result = await performSwitchBranch(worktreePath, branchName, events);
// Map service result to HTTP response
if (!result.success) {
// Determine status code based on error type
const statusCode = isBranchNotFoundError(result.error) ? 400 : 500;
res.status(statusCode).json({
success: false,
error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`,
code: 'UNCOMMITTED_CHANGES',
error: result.error,
...(result.stashPopConflicts !== undefined && {
stashPopConflicts: result.stashPopConflicts,
}),
...(result.stashPopConflictMessage && {
stashPopConflictMessage: result.stashPopConflictMessage,
}),
});
return;
}
// Switch to the target branch
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
res.json({
success: true,
result: {
previousBranch,
currentBranch: branchName,
message: `Switched to branch '${branchName}'`,
},
result: result.result,
});
} catch (error) {
events?.emit('switch:error', {
error: getErrorMessage(error),
});
logError(error, 'Switch branch failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
}
};
}
/**
* Determine whether an error message represents a client error (400)
* vs a server error (500).
*
* Client errors are validation issues like non-existent branches or
* unparseable remote branch names.
*/
function isBranchNotFoundError(error?: string): boolean {
if (!error) return false;
return error.includes('does not exist') || error.includes('Failed to parse remote branch name');
}

View File

@@ -0,0 +1,159 @@
import { Router, Request, Response } from 'express';
import { ZaiUsageService } from '../../services/zai-usage-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('Zai');
export function createZaiRoutes(
usageService: ZaiUsageService,
settingsService: SettingsService
): Router {
const router = Router();
// Initialize z.ai API token from credentials on startup
(async () => {
try {
const credentials = await settingsService.getCredentials();
if (credentials.apiKeys?.zai) {
usageService.setApiToken(credentials.apiKeys.zai);
logger.info('[init] Loaded z.ai API key from credentials');
}
} catch (error) {
logger.error('[init] Failed to load z.ai API key from credentials:', error);
}
})();
// Get current usage (fetches from z.ai API)
router.get('/usage', async (_req: Request, res: Response) => {
try {
// Check if z.ai API is configured
const isAvailable = usageService.isAvailable();
if (!isAvailable) {
// Use a 200 + error payload so the UI doesn't interpret it as session auth error
res.status(200).json({
error: 'z.ai API not configured',
message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking',
});
return;
}
const usage = await usageService.fetchUsageData();
res.json(usage);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes('not configured') || message.includes('API token')) {
res.status(200).json({
error: 'API token required',
message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking',
});
} else if (message.includes('failed') || message.includes('request')) {
res.status(200).json({
error: 'API request failed',
message: message,
});
} else {
logger.error('Error fetching z.ai usage:', error);
res.status(500).json({ error: message });
}
}
});
// Configure API token (for settings page)
router.post('/configure', async (req: Request, res: Response) => {
try {
const { apiToken, apiHost } = req.body;
// Validate apiToken: must be present and a string
if (apiToken === undefined || apiToken === null || typeof apiToken !== 'string') {
res.status(400).json({
success: false,
error: 'Invalid request: apiToken is required and must be a string',
});
return;
}
// Validate apiHost if provided: must be a string and a well-formed URL
if (apiHost !== undefined && apiHost !== null) {
if (typeof apiHost !== 'string') {
res.status(400).json({
success: false,
error: 'Invalid request: apiHost must be a string',
});
return;
}
// Validate that apiHost is a well-formed URL
try {
const parsedUrl = new URL(apiHost);
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
res.status(400).json({
success: false,
error: 'Invalid request: apiHost must be a valid HTTP or HTTPS URL',
});
return;
}
} catch {
res.status(400).json({
success: false,
error: 'Invalid request: apiHost must be a well-formed URL',
});
return;
}
}
// Pass only the sanitized values to the service
const sanitizedToken = apiToken.trim();
const sanitizedHost = typeof apiHost === 'string' ? apiHost.trim() : undefined;
const result = await usageService.configure(
{ apiToken: sanitizedToken, apiHost: sanitizedHost },
settingsService
);
res.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error configuring z.ai:', error);
res.status(500).json({ error: message });
}
});
// Verify API key without storing it (for testing in settings)
router.post('/verify', async (req: Request, res: Response) => {
try {
const { apiKey } = req.body;
const result = await usageService.verifyApiKey(apiKey);
res.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error verifying z.ai API key:', error);
res.json({
success: false,
authenticated: false,
error: `Network error: ${message}`,
});
}
});
// Check if z.ai is available
router.get('/status', async (_req: Request, res: Response) => {
try {
const isAvailable = usageService.isAvailable();
const hasEnvApiKey = Boolean(process.env.Z_AI_API_KEY);
const hasApiKey = usageService.getApiToken() !== null;
res.json({
success: true,
available: isAvailable,
hasApiKey,
hasEnvApiKey,
message: isAvailable ? 'z.ai API is configured' : 'z.ai API token not configured',
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -42,6 +42,27 @@ export class AgentExecutor {
private static readonly WRITE_DEBOUNCE_MS = 500;
private static readonly STREAM_HEARTBEAT_MS = 15_000;
/**
* Sanitize a provider error value into clean text.
* Coalesces to string, removes ANSI codes, strips leading "Error:" prefix,
* trims, and returns 'Unknown error' when empty.
*/
private static sanitizeProviderError(input: string | { error?: string } | undefined): string {
let raw: string;
if (typeof input === 'string') {
raw = input;
} else if (input && typeof input === 'object' && typeof input.error === 'string') {
raw = input.error;
} else {
raw = '';
}
const cleaned = raw
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim();
return cleaned || 'Unknown error';
}
constructor(
private eventBus: TypedEventBus,
private featureStateManager: FeatureStateManager,
@@ -255,7 +276,7 @@ export class AgentExecutor {
}
}
} else if (msg.type === 'error') {
throw new Error(msg.error || 'Unknown error');
throw new Error(AgentExecutor.sanitizeProviderError(msg.error));
} else if (msg.type === 'result' && msg.subtype === 'success') scheduleWrite();
}
await writeToFile();
@@ -390,9 +411,15 @@ export class AgentExecutor {
input: b.input,
});
}
} else if (msg.type === 'error')
throw new Error(msg.error || `Error during task ${task.id}`);
else if (msg.type === 'result' && msg.subtype === 'success') {
} else if (msg.type === 'error') {
// Clean the error: strip ANSI codes and redundant "Error: " prefix
const cleanedError =
(msg.error || `Error during task ${task.id}`)
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim() || `Error during task ${task.id}`;
throw new Error(cleanedError);
} else if (msg.type === 'result' && msg.subtype === 'success') {
taskOutput += msg.result || '';
responseText += msg.result || '';
}
@@ -444,17 +471,11 @@ export class AgentExecutor {
callbacks: AgentExecutorCallbacks
): Promise<{ responseText: string; tasksCompleted: number }> {
const {
workDir,
featureId,
projectPath,
abortController,
branchName = null,
planningMode = 'skip',
provider,
effectiveBareModel,
credentials,
claudeCompatibleProvider,
mcpServers,
sdkOptions,
} = options;
let responseText = initialResponseText,
@@ -562,7 +583,14 @@ export class AgentExecutor {
content: b.text,
});
}
if (msg.type === 'error') throw new Error(msg.error || 'Error during plan revision');
if (msg.type === 'error') {
const cleanedError =
(msg.error || 'Error during plan revision')
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim() || 'Error during plan revision';
throw new Error(cleanedError);
}
if (msg.type === 'result' && msg.subtype === 'success') revText += msg.result || '';
}
const mi = revText.indexOf('[SPEC_GENERATED]');
@@ -680,9 +708,15 @@ export class AgentExecutor {
input: b.input,
});
}
else if (msg.type === 'error')
throw new Error(msg.error || 'Unknown error during implementation');
else if (msg.type === 'result' && msg.subtype === 'success') responseText += msg.result || '';
else if (msg.type === 'error') {
const cleanedError =
(msg.error || 'Unknown error during implementation')
.replace(/\x1b\[[0-9;]*m/g, '')
.replace(/^Error:\s*/i, '')
.trim() || 'Unknown error during implementation';
throw new Error(cleanedError);
} else if (msg.type === 'result' && msg.subtype === 'success')
responseText += msg.result || '';
}
return { responseText };
}

View File

@@ -15,11 +15,9 @@ import {
loadContextFiles,
createLogger,
classifyError,
getUserFriendlyErrorMessage,
} from '@automaker/utils';
import { ProviderFactory } from '../providers/provider-factory.js';
import { createChatOptions, validateWorkingDirectory } from '../lib/sdk-options.js';
import { PathNotAllowedError } from '@automaker/platform';
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
@@ -98,6 +96,20 @@ export class AgentService {
await secureFs.mkdir(this.stateDir, { recursive: true });
}
/**
* Detect provider-side session errors (session not found, expired, etc.).
* Used to decide whether to clear a stale sdkSessionId.
*/
private isStaleSessionError(rawErrorText: string): boolean {
const errorLower = rawErrorText.toLowerCase();
return (
errorLower.includes('session not found') ||
errorLower.includes('session expired') ||
errorLower.includes('invalid session') ||
errorLower.includes('no such session')
);
}
/**
* Start or resume a conversation
*/
@@ -108,32 +120,26 @@ export class AgentService {
sessionId: string;
workingDirectory?: string;
}) {
if (!this.sessions.has(sessionId)) {
const messages = await this.loadSession(sessionId);
const metadata = await this.loadMetadata();
const sessionMetadata = metadata[sessionId];
// Determine the effective working directory
// ensureSession handles loading from disk if not in memory.
// For startConversation, we always want to create a session even if
// metadata doesn't exist yet (new session), so we fall back to creating one.
let session = await this.ensureSession(sessionId, workingDirectory);
if (!session) {
// Session doesn't exist on disk either — create a fresh in-memory session.
const effectiveWorkingDirectory = workingDirectory || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed using centralized validation
validateWorkingDirectory(resolvedWorkingDirectory);
// Load persisted queue
const promptQueue = await this.loadQueueState(sessionId);
this.sessions.set(sessionId, {
messages,
session = {
messages: [],
isRunning: false,
abortController: null,
workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId, // Load persisted SDK session ID
promptQueue,
});
promptQueue: [],
};
this.sessions.set(sessionId, session);
}
const session = this.sessions.get(sessionId)!;
return {
success: true,
messages: session.messages,
@@ -141,6 +147,98 @@ export class AgentService {
};
}
/**
* Ensure a session is loaded into memory.
*
* Sessions may exist on disk (in metadata and session files) but not be
* present in the in-memory Map — for example after a server restart, or
* when a client calls sendMessage before explicitly calling startConversation.
*
* This helper transparently loads the session from disk when it is missing
* from memory, eliminating "session not found" errors for sessions that
* were previously created but not yet initialized in memory.
*
* If both metadata and session files are missing, the session truly doesn't
* exist. A detailed diagnostic log is emitted so developers can track down
* how the invalid session ID was generated.
*
* @returns The in-memory Session object, or null if the session doesn't exist at all
*/
private async ensureSession(
sessionId: string,
workingDirectory?: string
): Promise<Session | null> {
const existing = this.sessions.get(sessionId);
if (existing) {
return existing;
}
// Try to load from disk — the session may have been created earlier
// (e.g. via createSession) but never initialized in memory.
let metadata: Record<string, SessionMetadata>;
let messages: Message[];
try {
[metadata, messages] = await Promise.all([this.loadMetadata(), this.loadSession(sessionId)]);
} catch (error) {
// Disk read failure should not be treated as "session not found" —
// it's a transient I/O problem. Log and return null so callers can
// surface an appropriate error message.
this.logger.error(
`Failed to load session ${sessionId} from disk (I/O error — NOT a missing session):`,
error
);
return null;
}
const sessionMetadata = metadata[sessionId];
// If there's no metadata AND no persisted messages, the session truly doesn't exist.
// Log diagnostic info to help track down how we ended up with an invalid session ID.
if (!sessionMetadata && messages.length === 0) {
this.logger.warn(
`Session "${sessionId}" not found: no metadata and no persisted messages. ` +
`This can happen when a session ID references a deleted/expired session, ` +
`or when the server restarted and the session was never persisted to disk. ` +
`Available session IDs in metadata: [${Object.keys(metadata).slice(0, 10).join(', ')}${Object.keys(metadata).length > 10 ? '...' : ''}]`
);
return null;
}
const effectiveWorkingDirectory =
workingDirectory || sessionMetadata?.workingDirectory || process.cwd();
const resolvedWorkingDirectory = path.resolve(effectiveWorkingDirectory);
// Validate that the working directory is allowed using centralized validation
try {
validateWorkingDirectory(resolvedWorkingDirectory);
} catch (validationError) {
this.logger.warn(
`Session "${sessionId}": working directory "${resolvedWorkingDirectory}" is not allowed — ` +
`returning null so callers treat it as a missing session. Error: ${(validationError as Error).message}`
);
return null;
}
// Load persisted queue
const promptQueue = await this.loadQueueState(sessionId);
const session: Session = {
messages,
isRunning: false,
abortController: null,
workingDirectory: resolvedWorkingDirectory,
sdkSessionId: sessionMetadata?.sdkSessionId,
promptQueue,
};
this.sessions.set(sessionId, session);
this.logger.info(
`Auto-initialized session ${sessionId} from disk ` +
`(${messages.length} messages, sdkSessionId: ${sessionMetadata?.sdkSessionId ? 'present' : 'none'})`
);
return session;
}
/**
* Send a message to the agent and stream responses
*/
@@ -161,10 +259,18 @@ export class AgentService {
thinkingLevel?: ThinkingLevel;
reasoningEffort?: ReasoningEffort;
}) {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId, workingDirectory);
if (!session) {
this.logger.error('ERROR: Session not found:', sessionId);
throw new Error(`Session ${sessionId} not found`);
this.logger.error(
`Session not found: ${sessionId}. ` +
`The session may have been deleted, never created, or lost after a server restart. ` +
`In-memory sessions: ${this.sessions.size}, requested ID: ${sessionId}`
);
throw new Error(
`Session ${sessionId} not found. ` +
`The session may have been deleted or expired. ` +
`Please create a new session and try again.`
);
}
if (session.isRunning) {
@@ -327,7 +433,7 @@ export class AgentService {
// When using a custom provider (GLM, MiniMax), use resolved Claude model for SDK config
// (thinking level budgets, allowedTools) but we MUST pass the provider's model ID
// (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-20250514" which causes "model not found"
// (e.g. "GLM-4.7") to the API - not "claude-sonnet-4-6" which causes "model not found"
const modelForSdk = providerResolvedModel || model;
const sessionModelForSdk = providerResolvedModel ? undefined : session.model;
@@ -441,8 +547,13 @@ export class AgentService {
const toolUses: Array<{ name: string; input: unknown }> = [];
for await (const msg of stream) {
// Capture SDK session ID from any message and persist it
if (msg.session_id && !session.sdkSessionId) {
// Capture SDK session ID from any message and persist it.
// Update when:
// - No session ID set yet (first message in a new session)
// - The provider returned a *different* session ID (e.g., after a
// "Session not found" recovery where the provider started a fresh
// session — the stale ID must be replaced with the new one)
if (msg.session_id && msg.session_id !== session.sdkSessionId) {
session.sdkSessionId = msg.session_id;
// Persist the SDK session ID to ensure conversation continuity across server restarts
await this.updateSession(sessionId, { sdkSessionId: msg.session_id });
@@ -505,12 +616,36 @@ export class AgentService {
// streamed error messages instead of throwing. Handle these here so the
// Agent Runner UX matches the Claude/Cursor behavior without changing
// their provider implementations.
const rawErrorText =
// Clean error text: strip ANSI escape codes and the redundant "Error: "
// prefix that CLI providers (especially OpenCode) add to stderr output.
// The OpenCode provider strips these in normalizeEvent/executeQuery, but
// we also strip here as a defense-in-depth measure.
//
// Without stripping the "Error: " prefix, the wrapping at line ~647
// (`content: \`Error: ${enhancedText}\``) produces double-prefixed text:
// "Error: Error: Session not found" — confusing for the user.
const rawMsgError =
(typeof msg.error === 'string' && msg.error.trim()) ||
'Unexpected error from provider during agent execution.';
let rawErrorText = rawMsgError.replace(/\x1b\[[0-9;]*m/g, '').trim() || rawMsgError;
// Remove the CLI's "Error: " prefix to prevent double-wrapping
rawErrorText = rawErrorText.replace(/^Error:\s*/i, '').trim() || rawErrorText;
const errorInfo = classifyError(new Error(rawErrorText));
// Detect provider-side session errors and proactively clear the stale
// sdkSessionId so the next attempt starts a fresh provider session.
// This handles providers that don't have built-in session recovery
// (unlike OpenCode which auto-retries without the session flag).
if (session.sdkSessionId && this.isStaleSessionError(rawErrorText)) {
this.logger.info(
`Clearing stale sdkSessionId for session ${sessionId} after provider session error`
);
session.sdkSessionId = undefined;
await this.clearSdkSessionId(sessionId);
}
// Keep the provider-supplied text intact (Codex already includes helpful tips),
// only add a small rate-limit hint when we can detect it.
const enhancedText = errorInfo.isRateLimit
@@ -571,13 +706,30 @@ export class AgentService {
this.logger.error('Error:', error);
// Strip ANSI escape codes and the "Error: " prefix from thrown error
// messages so the UI receives clean text without double-prefixing.
let rawThrownMsg = ((error as Error).message || '').replace(/\x1b\[[0-9;]*m/g, '').trim();
rawThrownMsg = rawThrownMsg.replace(/^Error:\s*/i, '').trim() || rawThrownMsg;
const thrownErrorMsg = rawThrownMsg.toLowerCase();
// Check if the thrown error is a provider-side session error.
// Clear the stale sdkSessionId so the next retry starts fresh.
if (session.sdkSessionId && this.isStaleSessionError(rawThrownMsg)) {
this.logger.info(
`Clearing stale sdkSessionId for session ${sessionId} after thrown session error`
);
session.sdkSessionId = undefined;
await this.clearSdkSessionId(sessionId);
}
session.isRunning = false;
session.abortController = null;
const cleanErrorMsg = rawThrownMsg || (error as Error).message;
const errorMessage: Message = {
id: this.generateId(),
role: 'assistant',
content: `Error: ${(error as Error).message}`,
content: `Error: ${cleanErrorMsg}`,
timestamp: new Date().toISOString(),
isError: true,
};
@@ -587,7 +739,7 @@ export class AgentService {
this.emitAgentEvent(sessionId, {
type: 'error',
error: (error as Error).message,
error: cleanErrorMsg,
message: errorMessage,
});
@@ -598,8 +750,8 @@ export class AgentService {
/**
* Get conversation history
*/
getHistory(sessionId: string) {
const session = this.sessions.get(sessionId);
async getHistory(sessionId: string) {
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -615,7 +767,7 @@ export class AgentService {
* Stop current agent execution
*/
async stopExecution(sessionId: string) {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -637,9 +789,16 @@ export class AgentService {
if (session) {
session.messages = [];
session.isRunning = false;
session.sdkSessionId = undefined; // Clear stale provider session ID to prevent "Session not found" errors
await this.saveSession(sessionId, []);
}
// Clear the sdkSessionId from persisted metadata so it doesn't get
// reloaded by ensureSession() after a server restart.
// This prevents "Session not found" errors when the provider-side session
// no longer exists (e.g., OpenCode CLI sessions expire on disk).
await this.clearSdkSessionId(sessionId);
return { success: true };
}
@@ -796,6 +955,23 @@ export class AgentService {
return true;
}
/**
* Clear the sdkSessionId from persisted metadata.
*
* This removes the provider-side session ID so that the next message
* starts a fresh provider session instead of trying to resume a stale one.
* Prevents "Session not found" errors from CLI providers like OpenCode
* when the provider-side session has been deleted or expired.
*/
async clearSdkSessionId(sessionId: string): Promise<void> {
const metadata = await this.loadMetadata();
if (metadata[sessionId] && metadata[sessionId].sdkSessionId) {
delete metadata[sessionId].sdkSessionId;
metadata[sessionId].updatedAt = new Date().toISOString();
await this.saveMetadata(metadata);
}
}
// Queue management methods
/**
@@ -810,7 +986,7 @@ export class AgentService {
thinkingLevel?: ThinkingLevel;
}
): Promise<{ success: boolean; queuedPrompt?: QueuedPrompt; error?: string }> {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -839,8 +1015,10 @@ export class AgentService {
/**
* Get the current queue for a session
*/
getQueue(sessionId: string): { success: boolean; queue?: QueuedPrompt[]; error?: string } {
const session = this.sessions.get(sessionId);
async getQueue(
sessionId: string
): Promise<{ success: boolean; queue?: QueuedPrompt[]; error?: string }> {
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -854,7 +1032,7 @@ export class AgentService {
sessionId: string,
promptId: string
): Promise<{ success: boolean; error?: string }> {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -879,7 +1057,7 @@ export class AgentService {
* Clear all prompts from the queue
*/
async clearQueue(sessionId: string): Promise<{ success: boolean; error?: string }> {
const session = this.sessions.get(sessionId);
const session = await this.ensureSession(sessionId);
if (!session) {
return { success: false, error: 'Session not found' };
}
@@ -962,10 +1140,24 @@ export class AgentService {
}
}
/**
* Emit an event to the agent stream (private, used internally).
*/
private emitAgentEvent(sessionId: string, data: Record<string, unknown>): void {
this.events.emit('agent:stream', { sessionId, ...data });
}
/**
* Emit an error event for a session.
*
* Public method so that route handlers can surface errors to the UI
* even when sendMessage() throws before it can emit its own error event
* (e.g., when the session is not found and no in-memory session exists).
*/
emitSessionError(sessionId: string, error: string): void {
this.events.emit('agent:stream', { sessionId, type: 'error', error });
}
private async getSystemPrompt(): Promise<string> {
// Load from settings (no caching - allows hot reload of custom prompts)
const prompts = await getPromptCustomization(this.settingsService, '[AgentService]');

View File

@@ -4,6 +4,7 @@
import type { Feature } from '@automaker/types';
import { createLogger, classifyError } from '@automaker/utils';
import { areDependenciesSatisfied } from '@automaker/dependency-resolver';
import type { TypedEventBus } from './typed-event-bus.js';
import type { ConcurrencyManager } from './concurrency-manager.js';
import type { SettingsService } from './settings-service.js';
@@ -64,6 +65,7 @@ export type ClearExecutionStateFn = (
) => Promise<void>;
export type ResetStuckFeaturesFn = (projectPath: string) => Promise<void>;
export type IsFeatureFinishedFn = (feature: Feature) => boolean;
export type LoadAllFeaturesFn = (projectPath: string) => Promise<Feature[]>;
export class AutoLoopCoordinator {
private autoLoopsByProject = new Map<string, ProjectAutoLoopState>();
@@ -78,7 +80,8 @@ export class AutoLoopCoordinator {
private clearExecutionStateFn: ClearExecutionStateFn,
private resetStuckFeaturesFn: ResetStuckFeaturesFn,
private isFeatureFinishedFn: IsFeatureFinishedFn,
private isFeatureRunningFn: (featureId: string) => boolean
private isFeatureRunningFn: (featureId: string) => boolean,
private loadAllFeaturesFn?: LoadAllFeaturesFn
) {}
/**
@@ -158,11 +161,12 @@ export class AutoLoopCoordinator {
const projectState = this.autoLoopsByProject.get(worktreeKey);
if (!projectState) return;
const { projectPath, branchName } = projectState.config;
let iterationCount = 0;
while (projectState.isRunning && !projectState.abortController.signal.aborted) {
iterationCount++;
try {
// Count ALL running features (both auto and manual) against the concurrency limit.
// This ensures auto mode is aware of the total system load and does not over-subscribe
// resources. Manual tasks always bypass the limit and run immediately, but their
// presence is accounted for when deciding whether to dispatch new auto-mode tasks.
const runningCount = await this.getRunningCountForWorktree(projectPath, branchName);
if (runningCount >= projectState.config.maxConcurrency) {
await this.sleep(5000, projectState.abortController.signal);
@@ -181,9 +185,34 @@ export class AutoLoopCoordinator {
await this.sleep(10000, projectState.abortController.signal);
continue;
}
const nextFeature = pendingFeatures.find(
(f) => !this.isFeatureRunningFn(f.id) && !this.isFeatureFinishedFn(f)
// Load all features for dependency checking (if callback provided)
const allFeatures = this.loadAllFeaturesFn
? await this.loadAllFeaturesFn(projectPath)
: undefined;
// Filter to eligible features: not running, not finished, and dependencies satisfied.
// When loadAllFeaturesFn is not provided, allFeatures is undefined and we bypass
// dependency checks (returning true) to avoid false negatives caused by completed
// features being absent from pendingFeatures.
const eligibleFeatures = pendingFeatures.filter(
(f) =>
!this.isFeatureRunningFn(f.id) &&
!this.isFeatureFinishedFn(f) &&
(this.loadAllFeaturesFn ? areDependenciesSatisfied(f, allFeatures!) : true)
);
// Sort eligible features by priority (lower number = higher priority, default 2)
eligibleFeatures.sort((a, b) => (a.priority ?? 2) - (b.priority ?? 2));
const nextFeature = eligibleFeatures[0] ?? null;
if (nextFeature) {
logger.info(
`Auto-loop selected feature "${nextFeature.title || nextFeature.id}" ` +
`(priority=${nextFeature.priority ?? 2}) from ${eligibleFeatures.length} eligible features`
);
}
if (nextFeature) {
projectState.hasEmittedIdleEvent = false;
this.executeFeatureFn(
@@ -273,11 +302,17 @@ export class AutoLoopCoordinator {
return Array.from(activeProjects);
}
/**
* Get the number of running features for a worktree.
* By default counts ALL running features (both auto-mode and manual).
* Pass `autoModeOnly: true` to count only auto-mode features.
*/
async getRunningCountForWorktree(
projectPath: string,
branchName: string | null
branchName: string | null,
options?: { autoModeOnly?: boolean }
): Promise<number> {
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName);
return this.concurrencyManager.getRunningCountForWorktree(projectPath, branchName, options);
}
trackFailureAndCheckPauseForProject(
@@ -390,6 +425,10 @@ export class AutoLoopCoordinator {
const projectId = settings.projects?.find((p) => p.path === projectPath)?.id;
const autoModeByWorktree = settings.autoModeByWorktree;
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
// Normalize both null and 'main' to '__main__' to match the same
// canonicalization used by getWorktreeAutoLoopKey, ensuring that
// lookups for the primary branch always use the '__main__' sentinel
// regardless of whether the caller passed null or the string 'main'.
const normalizedBranch =
branchName === null || branchName === 'main' ? '__main__' : branchName;
const worktreeId = `${projectId}::${normalizedBranch}`;

View File

@@ -15,12 +15,14 @@ import path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import type { Feature, PlanningMode, ThinkingLevel } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY, stripProviderPrefix } from '@automaker/types';
import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types';
import { resolveModelString } from '@automaker/model-resolver';
import { createLogger, loadContextFiles, classifyError } from '@automaker/utils';
import { getFeatureDir, spawnProcess } from '@automaker/platform';
import { getFeatureDir } from '@automaker/platform';
import * as secureFs from '../../lib/secure-fs.js';
import { validateWorkingDirectory } from '../../lib/sdk-options.js';
import { getPromptCustomization, getProviderByModelId } from '../../lib/settings-helpers.js';
import { execGitCommand } from '@automaker/git-utils';
import { TypedEventBus } from '../typed-event-bus.js';
import { ConcurrencyManager } from '../concurrency-manager.js';
import { WorktreeResolver } from '../worktree-resolver.js';
@@ -49,24 +51,6 @@ import type {
const execAsync = promisify(exec);
const logger = createLogger('AutoModeServiceFacade');
/**
* Execute git command with array arguments to prevent command injection.
*/
async function execGitCommand(args: string[], cwd: string): Promise<string> {
const result = await spawnProcess({
command: 'git',
args,
cwd,
});
if (result.exitCode === 0) {
return result.stdout;
} else {
const errorMessage = result.stderr || `Git command failed with code ${result.exitCode}`;
throw new Error(errorMessage);
}
}
/**
* AutoModeServiceFacade provides a clean interface for auto-mode functionality.
*
@@ -198,23 +182,18 @@ export class AutoModeServiceFacade {
return facadeInstance;
};
// PipelineOrchestrator - runAgentFn is a stub; routes use AutoModeService directly
const pipelineOrchestrator = new PipelineOrchestrator(
eventBus,
featureStateManager,
agentExecutor,
testRunnerService,
worktreeResolver,
concurrencyManager,
settingsService,
// Callbacks
(pPath, featureId, status) =>
featureStateManager.updateFeatureStatus(pPath, featureId, status),
loadContextFiles,
buildFeaturePrompt,
(pPath, featureId, useWorktrees, _isAutoMode, _model, opts) =>
getFacade().executeFeature(featureId, useWorktrees, false, undefined, opts),
// runAgentFn - delegates to AgentExecutor
/**
* Shared agent-run helper used by both PipelineOrchestrator and ExecutionService.
*
* Resolves the model string, looks up the custom provider/credentials via
* getProviderByModelId, then delegates to agentExecutor.execute with the
* full payload. The opts parameter uses an index-signature union so it
* accepts both the typed ExecutionService opts object and the looser
* Record<string, unknown> used by PipelineOrchestrator without requiring
* type casts at the call sites.
*/
const createRunAgentFn =
() =>
async (
workDir: string,
featureId: string,
@@ -223,9 +202,18 @@ export class AutoModeServiceFacade {
pPath: string,
imagePaths?: string[],
model?: string,
opts?: Record<string, unknown>
) => {
const resolvedModel = model || 'claude-sonnet-4-20250514';
opts?: {
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
previousContent?: string;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
thinkingLevel?: ThinkingLevel;
branchName?: string | null;
[key: string]: unknown;
}
): Promise<void> => {
const resolvedModel = resolveModelString(model, DEFAULT_MODELS.claude);
const provider = ProviderFactory.getProviderForModel(resolvedModel);
const effectiveBareModel = stripProviderPrefix(resolvedModel);
@@ -234,7 +222,7 @@ export class AutoModeServiceFacade {
| import('@automaker/types').ClaudeCompatibleProvider
| undefined;
let credentials: import('@automaker/types').Credentials | undefined;
if (resolvedModel && settingsService) {
if (settingsService) {
const providerResult = await getProviderByModelId(
resolvedModel,
settingsService,
@@ -275,7 +263,7 @@ export class AutoModeServiceFacade {
featureStateManager.saveFeatureSummary(projPath, fId, summary),
buildTaskPrompt: (task, allTasks, taskIndex, _planContent, template, feedback) => {
let taskPrompt = template
.replace(/\{\{taskName\}\}/g, task.description)
.replace(/\{\{taskName\}\}/g, task.description || `Task ${task.id}`)
.replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1))
.replace(/\{\{totalTasks\}\}/g, String(allTasks.length))
.replace(/\{\{taskDescription\}\}/g, task.description || `Task ${task.id}`);
@@ -286,7 +274,25 @@ export class AutoModeServiceFacade {
},
}
);
}
};
// PipelineOrchestrator - runAgentFn delegates to AgentExecutor via shared helper
const pipelineOrchestrator = new PipelineOrchestrator(
eventBus,
featureStateManager,
agentExecutor,
testRunnerService,
worktreeResolver,
concurrencyManager,
settingsService,
// Callbacks
(pPath, featureId, status) =>
featureStateManager.updateFeatureStatus(pPath, featureId, status),
loadContextFiles,
buildFeaturePrompt,
(pPath, featureId, useWorktrees, _isAutoMode, _model, opts) =>
getFacade().executeFeature(featureId, useWorktrees, false, undefined, opts),
createRunAgentFn()
);
// AutoLoopCoordinator - ALWAYS create new with proper execution callbacks
@@ -324,95 +330,34 @@ export class AutoModeServiceFacade {
feature.status === 'completed' ||
feature.status === 'verified' ||
feature.status === 'waiting_approval',
(featureId) => concurrencyManager.isRunning(featureId)
(featureId) => concurrencyManager.isRunning(featureId),
async (pPath) => featureLoader.getAll(pPath)
);
// ExecutionService - runAgentFn calls AgentExecutor.execute
/**
* Iterate all active worktrees for this project, falling back to the
* main worktree (null) when none are active.
*/
const forEachProjectWorktree = (fn: (branchName: string | null) => void): void => {
const projectWorktrees = autoLoopCoordinator
.getActiveWorktrees()
.filter((w) => w.projectPath === projectPath);
if (projectWorktrees.length === 0) {
fn(null);
} else {
for (const w of projectWorktrees) {
fn(w.branchName);
}
}
};
// ExecutionService - runAgentFn delegates to AgentExecutor via shared helper
const executionService = new ExecutionService(
eventBus,
concurrencyManager,
worktreeResolver,
settingsService,
// runAgentFn - delegates to AgentExecutor
async (
workDir: string,
featureId: string,
prompt: string,
abortController: AbortController,
pPath: string,
imagePaths?: string[],
model?: string,
opts?: {
projectPath?: string;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
systemPrompt?: string;
autoLoadClaudeMd?: boolean;
thinkingLevel?: ThinkingLevel;
branchName?: string | null;
}
) => {
const resolvedModel = model || 'claude-sonnet-4-20250514';
const provider = ProviderFactory.getProviderForModel(resolvedModel);
const effectiveBareModel = stripProviderPrefix(resolvedModel);
// Resolve custom provider (GLM, MiniMax, etc.) for baseUrl and credentials
let claudeCompatibleProvider:
| import('@automaker/types').ClaudeCompatibleProvider
| undefined;
let credentials: import('@automaker/types').Credentials | undefined;
if (resolvedModel && settingsService) {
const providerResult = await getProviderByModelId(
resolvedModel,
settingsService,
'[AutoModeFacade]'
);
if (providerResult.provider) {
claudeCompatibleProvider = providerResult.provider;
credentials = providerResult.credentials;
}
}
await agentExecutor.execute(
{
workDir,
featureId,
prompt,
projectPath: pPath,
abortController,
imagePaths,
model: resolvedModel,
planningMode: opts?.planningMode,
requirePlanApproval: opts?.requirePlanApproval,
systemPrompt: opts?.systemPrompt,
autoLoadClaudeMd: opts?.autoLoadClaudeMd,
thinkingLevel: opts?.thinkingLevel,
branchName: opts?.branchName,
provider,
effectiveBareModel,
credentials,
claudeCompatibleProvider,
},
{
waitForApproval: (fId, projPath) => planApprovalService.waitForApproval(fId, projPath),
saveFeatureSummary: (projPath, fId, summary) =>
featureStateManager.saveFeatureSummary(projPath, fId, summary),
updateFeatureSummary: (projPath, fId, summary) =>
featureStateManager.saveFeatureSummary(projPath, fId, summary),
buildTaskPrompt: (task, allTasks, taskIndex, planContent, template, feedback) => {
let taskPrompt = template
.replace(/\{\{taskName\}\}/g, task.description)
.replace(/\{\{taskIndex\}\}/g, String(taskIndex + 1))
.replace(/\{\{totalTasks\}\}/g, String(allTasks.length))
.replace(/\{\{taskDescription\}\}/g, task.description || task.description);
if (feedback) {
taskPrompt = taskPrompt.replace(/\{\{userFeedback\}\}/g, feedback);
}
return taskPrompt;
},
}
);
},
createRunAgentFn(),
(context) => pipelineOrchestrator.executePipeline(context),
(pPath, featureId, status) =>
featureStateManager.updateFeatureStatus(pPath, featureId, status),
@@ -429,11 +374,36 @@ export class AutoModeServiceFacade {
(pPath, featureId) => getFacade().contextExists(featureId),
(pPath, featureId, useWorktrees, _calledInternally) =>
getFacade().resumeFeature(featureId, useWorktrees, _calledInternally),
(errorInfo) =>
autoLoopCoordinator.trackFailureAndCheckPauseForProject(projectPath, null, errorInfo),
(errorInfo) => autoLoopCoordinator.signalShouldPauseForProject(projectPath, null, errorInfo),
(errorInfo) => {
// Track failure against ALL active worktrees for this project.
// The ExecutionService callbacks don't receive branchName, so we
// iterate all active worktrees. Uses a for-of loop (not .some()) to
// ensure every worktree's failure counter is incremented.
let shouldPause = false;
forEachProjectWorktree((branchName) => {
if (
autoLoopCoordinator.trackFailureAndCheckPauseForProject(
projectPath,
branchName,
errorInfo
)
) {
shouldPause = true;
}
});
return shouldPause;
},
(errorInfo) => {
forEachProjectWorktree((branchName) =>
autoLoopCoordinator.signalShouldPauseForProject(projectPath, branchName, errorInfo)
);
},
() => {
/* recordSuccess - no-op */
// Record success to clear failure tracking. This prevents failures
// from accumulating over time and incorrectly pausing auto mode.
forEachProjectWorktree((branchName) =>
autoLoopCoordinator.recordSuccessForProject(projectPath, branchName)
);
},
(_pPath) => getFacade().saveExecutionState(),
loadContextFiles
@@ -591,12 +561,22 @@ export class AutoModeServiceFacade {
useWorktrees = false,
_calledInternally = false
): Promise<void> {
return this.recoveryService.resumeFeature(
// Note: ExecutionService.executeFeature catches its own errors internally and
// does NOT re-throw them (it emits auto_mode_error and returns normally).
// Therefore, errors that reach this catch block are pre-execution failures
// (e.g., feature not found, context read error) that ExecutionService never
// handled — so calling handleFacadeError here does NOT produce duplicate events.
try {
return await this.recoveryService.resumeFeature(
this.projectPath,
featureId,
useWorktrees,
_calledInternally
);
} catch (error) {
this.handleFacadeError(error, 'resumeFeature', featureId);
throw error;
}
}
/**

View File

@@ -10,7 +10,6 @@
*/
import path from 'path';
import type { Feature } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import type { EventEmitter } from '../../lib/events.js';
import { TypedEventBus } from '../typed-event-bus.js';

View File

@@ -0,0 +1,172 @@
/**
* Service for fetching branch commit log data.
*
* Extracts the heavy Git command execution and parsing logic from the
* branch-commit-log route handler so the handler only validates input,
* invokes this service, streams lifecycle events, and sends the response.
*/
import { execGitCommand } from '../lib/git.js';
// ============================================================================
// Types
// ============================================================================
export interface BranchCommit {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
export interface BranchCommitLogResult {
branch: string;
commits: BranchCommit[];
total: number;
}
// ============================================================================
// Service
// ============================================================================
/**
* Fetch the commit log for a specific branch (or HEAD).
*
* Runs a single `git log --name-only` invocation (plus `git rev-parse`
* when branchName is omitted) inside the given worktree path and
* returns a structured result.
*
* @param worktreePath - Absolute path to the worktree / repository
* @param branchName - Branch to query (omit or pass undefined for HEAD)
* @param limit - Maximum number of commits to return (clamped 1-100)
*/
export async function getBranchCommitLog(
worktreePath: string,
branchName: string | undefined,
limit: number
): Promise<BranchCommitLogResult> {
// Clamp limit to a reasonable range
const parsedLimit = Number(limit);
const commitLimit = Math.min(Math.max(1, Number.isFinite(parsedLimit) ? parsedLimit : 20), 100);
// Use the specified branch or default to HEAD
const targetRef = branchName || 'HEAD';
// Fetch commit metadata AND file lists in a single git call.
// Uses custom record separators so we can parse both metadata and
// --name-only output from one invocation, eliminating the previous
// N+1 pattern that spawned a separate `git diff-tree` per commit.
//
// -m causes merge commits to be diffed against each parent so all
// files touched by the merge are listed (without -m, --name-only
// produces no file output for merge commits because they have 2+ parents).
// This means merge commits appear multiple times in the output (once per
// parent), so we deduplicate by hash below and merge their file lists.
// We over-fetch (2× the limit) to compensate for -m duplicating merge
// commit entries, then trim the result to the requested limit.
// Use ASCII control characters as record separators these cannot appear in
// git commit messages, so these delimiters are safe regardless of commit
// body content. %x00 and %x01 in git's format string emit literal NUL /
// SOH bytes respectively.
//
// COMMIT_SEP (\x00) marks the start of each commit record.
// META_END (\x01) separates commit metadata from the --name-only file list.
//
// Full per-commit layout emitted by git:
// \x00\n<hash>\n<shorthash>\n...\n<subject>\n<body>\x01<files...>
const COMMIT_SEP = '\x00';
const META_END = '\x01';
const fetchLimit = commitLimit * 2;
const logOutput = await execGitCommand(
[
'log',
targetRef,
`--max-count=${fetchLimit}`,
'-m',
'--name-only',
`--format=%x00%n%H%n%h%n%an%n%ae%n%aI%n%s%n%b%x01`,
],
worktreePath
);
// Split output into per-commit blocks and drop the empty first chunk
// (the output starts with a NUL commit separator).
const commitBlocks = logOutput.split(COMMIT_SEP).filter((block) => block.trim());
// Use a Map to deduplicate merge commit entries (which appear once per
// parent when -m is used) while preserving insertion order.
const commitMap = new Map<string, BranchCommit>();
for (const block of commitBlocks) {
const metaEndIdx = block.indexOf(META_END);
if (metaEndIdx === -1) continue; // malformed block, skip
// --- Parse metadata (everything before the META_END delimiter) ---
const metaRaw = block.substring(0, metaEndIdx);
const metaLines = metaRaw.split('\n');
// The first line may be empty (newline right after COMMIT_SEP), skip it
const nonEmptyStart = metaLines.findIndex((l) => l.trim() !== '');
if (nonEmptyStart === -1) continue;
const fields = metaLines.slice(nonEmptyStart);
if (fields.length < 6) continue; // need at least hash..subject
const hash = fields[0].trim();
if (!hash) continue; // defensive: skip if hash is empty
const shortHash = fields[1]?.trim() ?? '';
const author = fields[2]?.trim() ?? '';
const authorEmail = fields[3]?.trim() ?? '';
const date = fields[4]?.trim() ?? '';
const subject = fields[5]?.trim() ?? '';
const body = fields.slice(6).join('\n').trim();
// --- Parse file list (everything after the META_END delimiter) ---
const filesRaw = block.substring(metaEndIdx + META_END.length);
const blockFiles = filesRaw
.trim()
.split('\n')
.filter((f) => f.trim());
// Merge file lists for duplicate entries (merge commits with -m)
const existing = commitMap.get(hash);
if (existing) {
// Add new files to the existing entry's file set
const fileSet = new Set(existing.files);
for (const f of blockFiles) fileSet.add(f);
existing.files = [...fileSet];
} else {
commitMap.set(hash, {
hash,
shortHash,
author,
authorEmail,
date,
subject,
body,
files: [...new Set(blockFiles)],
});
}
}
// Trim to the requested limit (we over-fetched to account for -m duplicates)
const commits = [...commitMap.values()].slice(0, commitLimit);
// If branchName wasn't specified, get current branch for display
let displayBranch = branchName;
if (!displayBranch) {
const branchOutput = await execGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath);
displayBranch = branchOutput.trim();
}
return {
branch: displayBranch,
commits,
total: commits.length,
};
}

Some files were not shown because too many files have changed in this diff Show More