diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts
index 59cc6f57..f763c08d 100644
--- a/apps/server/src/index.ts
+++ b/apps/server/src/index.ts
@@ -217,7 +217,7 @@ app.use('/api/sessions', createSessionsRoutes(agentService));
app.use('/api/features', createFeaturesRoutes(featureLoader));
app.use('/api/auto-mode', createAutoModeRoutes(autoModeService));
app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService));
-app.use('/api/worktree', createWorktreeRoutes());
+app.use('/api/worktree', createWorktreeRoutes(events));
app.use('/api/git', createGitRoutes());
app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService));
app.use('/api/models', createModelsRoutes());
diff --git a/apps/server/src/lib/worktree-metadata.ts b/apps/server/src/lib/worktree-metadata.ts
index edeadc5b..3f7ea60d 100644
--- a/apps/server/src/lib/worktree-metadata.ts
+++ b/apps/server/src/lib/worktree-metadata.ts
@@ -21,6 +21,12 @@ export interface WorktreeMetadata {
branch: string;
createdAt: string;
pr?: WorktreePRInfo;
+ /** Whether the init script has been executed for this worktree */
+ initScriptRan?: boolean;
+ /** Status of the init script execution */
+ initScriptStatus?: 'running' | 'success' | 'failed';
+ /** Error message if init script failed */
+ initScriptError?: string;
}
/**
diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts
index 71dc3bd9..b6c257a0 100644
--- a/apps/server/src/routes/backlog-plan/routes/apply.ts
+++ b/apps/server/src/routes/backlog-plan/routes/apply.ts
@@ -12,11 +12,22 @@ const featureLoader = new FeatureLoader();
export function createApplyHandler() {
return async (req: Request, res: Response): Promise => {
try {
- const { projectPath, plan } = req.body as {
+ const {
+ projectPath,
+ plan,
+ branchName: rawBranchName,
+ } = req.body as {
projectPath: string;
plan: BacklogPlanResult;
+ branchName?: string;
};
+ // Validate branchName: must be undefined or a non-empty trimmed string
+ const branchName =
+ typeof rawBranchName === 'string' && rawBranchName.trim().length > 0
+ ? rawBranchName.trim()
+ : undefined;
+
if (!projectPath) {
res.status(400).json({ success: false, error: 'projectPath required' });
return;
@@ -82,6 +93,7 @@ export function createApplyHandler() {
dependencies: change.feature.dependencies,
priority: change.feature.priority,
status: 'backlog',
+ branchName,
});
appliedChanges.push(`added:${newFeature.id}`);
diff --git a/apps/server/src/routes/worktree/common.ts b/apps/server/src/routes/worktree/common.ts
index 4f63a382..75c3a437 100644
--- a/apps/server/src/routes/worktree/common.ts
+++ b/apps/server/src/routes/worktree/common.ts
@@ -3,15 +3,51 @@
*/
import { createLogger } from '@automaker/utils';
+import { spawnProcess } from '@automaker/platform';
import { exec } from 'child_process';
import { promisify } from 'util';
-import path from 'path';
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
-import { FeatureLoader } from '../../services/feature-loader.js';
const logger = createLogger('Worktree');
export const execAsync = promisify(exec);
-const featureLoader = new FeatureLoader();
+
+// ============================================================================
+// 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 {
+ 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
@@ -99,18 +135,6 @@ export function normalizePath(p: string): string {
return p.replace(/\\/g, '/');
}
-/**
- * Check if a path is a git repo
- */
-export async function isGitRepo(repoPath: string): Promise {
- try {
- await execAsync('git rev-parse --is-inside-work-tree', { cwd: repoPath });
- return true;
- } catch {
- return false;
- }
-}
-
/**
* Check if a git repository has at least one commit (i.e., HEAD exists)
* Returns false for freshly initialized repos with no commits
diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts
index 7972dcd6..a00e0bfe 100644
--- a/apps/server/src/routes/worktree/index.ts
+++ b/apps/server/src/routes/worktree/index.ts
@@ -3,6 +3,7 @@
*/
import { Router } from 'express';
+import type { EventEmitter } from '../../lib/events.js';
import { validatePathParams } from '../../middleware/validate-paths.js';
import { requireValidWorktree, requireValidProject, requireGitRepoOnly } from './middleware.js';
import { createInfoHandler } from './routes/info.js';
@@ -32,8 +33,14 @@ import { createMigrateHandler } from './routes/migrate.js';
import { createStartDevHandler } from './routes/start-dev.js';
import { createStopDevHandler } from './routes/stop-dev.js';
import { createListDevServersHandler } from './routes/list-dev-servers.js';
+import {
+ createGetInitScriptHandler,
+ createPutInitScriptHandler,
+ createDeleteInitScriptHandler,
+ createRunInitScriptHandler,
+} from './routes/init-script.js';
-export function createWorktreeRoutes(): Router {
+export function createWorktreeRoutes(events: EventEmitter): Router {
const router = Router();
router.post('/info', validatePathParams('projectPath'), createInfoHandler());
@@ -47,7 +54,7 @@ export function createWorktreeRoutes(): Router {
requireValidProject,
createMergeHandler()
);
- router.post('/create', validatePathParams('projectPath'), createCreateHandler());
+ router.post('/create', validatePathParams('projectPath'), createCreateHandler(events));
router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler());
router.post('/create-pr', createCreatePRHandler());
router.post('/pr-info', createPRInfoHandler());
@@ -91,5 +98,15 @@ export function createWorktreeRoutes(): Router {
router.post('/stop-dev', createStopDevHandler());
router.post('/list-dev-servers', createListDevServersHandler());
+ // Init script routes
+ router.get('/init-script', createGetInitScriptHandler());
+ router.put('/init-script', validatePathParams('projectPath'), createPutInitScriptHandler());
+ router.delete('/init-script', validatePathParams('projectPath'), createDeleteInitScriptHandler());
+ router.post(
+ '/run-init-script',
+ validatePathParams('projectPath', 'worktreePath'),
+ createRunInitScriptHandler(events)
+ );
+
return router;
}
diff --git a/apps/server/src/routes/worktree/middleware.ts b/apps/server/src/routes/worktree/middleware.ts
index d933fff4..eb83377f 100644
--- a/apps/server/src/routes/worktree/middleware.ts
+++ b/apps/server/src/routes/worktree/middleware.ts
@@ -3,7 +3,8 @@
*/
import type { Request, Response, NextFunction } from 'express';
-import { isGitRepo, hasCommits } from './common.js';
+import { isGitRepo } from '@automaker/git-utils';
+import { hasCommits } from './common.js';
interface ValidationOptions {
/** Check if the path is a git repository (default: true) */
diff --git a/apps/server/src/routes/worktree/routes/create.ts b/apps/server/src/routes/worktree/routes/create.ts
index b8e07570..061fa801 100644
--- a/apps/server/src/routes/worktree/routes/create.ts
+++ b/apps/server/src/routes/worktree/routes/create.ts
@@ -12,15 +12,19 @@ import { exec } from 'child_process';
import { promisify } from 'util';
import path from 'path';
import * as secureFs from '../../../lib/secure-fs.js';
+import type { EventEmitter } from '../../../lib/events.js';
+import { isGitRepo } from '@automaker/git-utils';
import {
- isGitRepo,
getErrorMessage,
logError,
normalizePath,
ensureInitialCommit,
+ isValidBranchName,
+ execGitCommand,
} from '../common.js';
import { trackBranch } from './branch-tracking.js';
import { createLogger } from '@automaker/utils';
+import { runInitScript } from '../../../services/init-script-service.js';
const logger = createLogger('Worktree');
@@ -77,7 +81,7 @@ async function findExistingWorktreeForBranch(
}
}
-export function createCreateHandler() {
+export function createCreateHandler(events: EventEmitter) {
return async (req: Request, res: Response): Promise => {
try {
const { projectPath, branchName, baseBranch } = req.body as {
@@ -94,6 +98,26 @@ export function createCreateHandler() {
return;
}
+ // Validate branch name to prevent command injection
+ if (!isValidBranchName(branchName)) {
+ res.status(400).json({
+ success: false,
+ error:
+ 'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
+ });
+ return;
+ }
+
+ // Validate base branch if provided
+ if (baseBranch && !isValidBranchName(baseBranch) && baseBranch !== 'HEAD') {
+ res.status(400).json({
+ success: false,
+ error:
+ 'Invalid base branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
+ });
+ return;
+ }
+
if (!(await isGitRepo(projectPath))) {
res.status(400).json({
success: false,
@@ -143,30 +167,28 @@ export function createCreateHandler() {
// Create worktrees directory if it doesn't exist
await secureFs.mkdir(worktreesDir, { recursive: true });
- // Check if branch exists
+ // Check if branch exists (using array arguments to prevent injection)
let branchExists = false;
try {
- await execAsync(`git rev-parse --verify ${branchName}`, {
- cwd: projectPath,
- });
+ await execGitCommand(['rev-parse', '--verify', branchName], projectPath);
branchExists = true;
} catch {
// Branch doesn't exist
}
- // Create worktree
- let createCmd: string;
+ // Create worktree (using array arguments to prevent injection)
if (branchExists) {
// Use existing branch
- createCmd = `git worktree add "${worktreePath}" ${branchName}`;
+ await execGitCommand(['worktree', 'add', worktreePath, branchName], projectPath);
} else {
// Create new branch from base or HEAD
const base = baseBranch || 'HEAD';
- createCmd = `git worktree add -b ${branchName} "${worktreePath}" ${base}`;
+ await execGitCommand(
+ ['worktree', 'add', '-b', branchName, worktreePath, base],
+ projectPath
+ );
}
- await execAsync(createCmd, { cwd: projectPath });
-
// Note: We intentionally do NOT symlink .automaker to worktrees
// Features and config are always accessed from the main project path
// This avoids symlink loop issues when activating worktrees
@@ -177,6 +199,8 @@ export function createCreateHandler() {
// Resolve to absolute path for cross-platform compatibility
// normalizePath converts to forward slashes for API consistency
const absoluteWorktreePath = path.resolve(worktreePath);
+
+ // Respond immediately (non-blocking)
res.json({
success: true,
worktree: {
@@ -185,6 +209,17 @@ export function createCreateHandler() {
isNew: !branchExists,
},
});
+
+ // Trigger init script asynchronously after response
+ // runInitScript internally checks if script exists and hasn't already run
+ runInitScript({
+ projectPath,
+ worktreePath: absoluteWorktreePath,
+ branch: branchName,
+ emitter: events,
+ }).catch((err) => {
+ logger.error(`Init script failed for ${branchName}:`, err);
+ });
} catch (error) {
logError(error, 'Create worktree failed');
res.status(500).json({ success: false, error: getErrorMessage(error) });
diff --git a/apps/server/src/routes/worktree/routes/delete.ts b/apps/server/src/routes/worktree/routes/delete.ts
index 93857f78..9d8f9d27 100644
--- a/apps/server/src/routes/worktree/routes/delete.ts
+++ b/apps/server/src/routes/worktree/routes/delete.ts
@@ -6,9 +6,11 @@ import type { Request, Response } from 'express';
import { exec } from 'child_process';
import { promisify } from 'util';
import { isGitRepo } from '@automaker/git-utils';
-import { getErrorMessage, logError } from '../common.js';
+import { getErrorMessage, logError, isValidBranchName, execGitCommand } from '../common.js';
+import { createLogger } from '@automaker/utils';
const execAsync = promisify(exec);
+const logger = createLogger('Worktree');
export function createDeleteHandler() {
return async (req: Request, res: Response): Promise => {
@@ -46,22 +48,25 @@ export function createDeleteHandler() {
// Could not get branch name
}
- // Remove the worktree
+ // Remove the worktree (using array arguments to prevent injection)
try {
- await execAsync(`git worktree remove "${worktreePath}" --force`, {
- cwd: projectPath,
- });
+ await execGitCommand(['worktree', 'remove', worktreePath, '--force'], projectPath);
} catch (error) {
// Try with prune if remove fails
- await execAsync('git worktree prune', { cwd: projectPath });
+ await execGitCommand(['worktree', 'prune'], projectPath);
}
// Optionally delete the branch
if (deleteBranch && branchName && branchName !== 'main' && branchName !== 'master') {
- try {
- await execAsync(`git branch -D ${branchName}`, { cwd: projectPath });
- } catch {
- // Branch deletion failed, not critical
+ // Validate branch name to prevent command injection
+ if (!isValidBranchName(branchName)) {
+ logger.warn(`Invalid branch name detected, skipping deletion: ${branchName}`);
+ } else {
+ try {
+ await execGitCommand(['branch', '-D', branchName], projectPath);
+ } catch {
+ // Branch deletion failed, not critical
+ }
}
}
diff --git a/apps/server/src/routes/worktree/routes/init-script.ts b/apps/server/src/routes/worktree/routes/init-script.ts
new file mode 100644
index 00000000..bb04d706
--- /dev/null
+++ b/apps/server/src/routes/worktree/routes/init-script.ts
@@ -0,0 +1,270 @@
+/**
+ * Init Script routes - Read/write/run the worktree-init.sh file
+ *
+ * POST /init-script - Read the init script content
+ * PUT /init-script - Write content to the init script file
+ * DELETE /init-script - Delete the init script file
+ * POST /run-init-script - Run the init script for a worktree
+ */
+
+import type { Request, Response } from 'express';
+import path from 'path';
+import * as secureFs from '../../../lib/secure-fs.js';
+import { getErrorMessage, logError, isValidBranchName } from '../common.js';
+import { createLogger } from '@automaker/utils';
+import type { EventEmitter } from '../../../lib/events.js';
+import { forceRunInitScript } from '../../../services/init-script-service.js';
+
+const logger = createLogger('InitScript');
+
+/** Fixed path for init script within .automaker directory */
+const INIT_SCRIPT_FILENAME = 'worktree-init.sh';
+
+/** Maximum allowed size for init scripts (1MB) */
+const MAX_SCRIPT_SIZE_BYTES = 1024 * 1024;
+
+/**
+ * Get the full path to the init script for a project
+ */
+function getInitScriptPath(projectPath: string): string {
+ return path.join(projectPath, '.automaker', INIT_SCRIPT_FILENAME);
+}
+
+/**
+ * GET /init-script - Read the init script content
+ */
+export function createGetInitScriptHandler() {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const projectPath = req.query.projectPath as string;
+
+ if (!projectPath) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath query parameter is required',
+ });
+ return;
+ }
+
+ const scriptPath = getInitScriptPath(projectPath);
+
+ try {
+ const content = await secureFs.readFile(scriptPath, 'utf-8');
+ res.json({
+ success: true,
+ exists: true,
+ content: content as string,
+ path: scriptPath,
+ });
+ } catch {
+ // File doesn't exist
+ res.json({
+ success: true,
+ exists: false,
+ content: '',
+ path: scriptPath,
+ });
+ }
+ } catch (error) {
+ logError(error, 'Read init script failed');
+ res.status(500).json({
+ success: false,
+ error: getErrorMessage(error),
+ });
+ }
+ };
+}
+
+/**
+ * PUT /init-script - Write content to the init script file
+ */
+export function createPutInitScriptHandler() {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, content } = req.body as {
+ projectPath: string;
+ content: string;
+ };
+
+ if (!projectPath) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath is required',
+ });
+ return;
+ }
+
+ if (typeof content !== 'string') {
+ res.status(400).json({
+ success: false,
+ error: 'content must be a string',
+ });
+ return;
+ }
+
+ // Validate script size to prevent disk exhaustion
+ const sizeBytes = Buffer.byteLength(content, 'utf-8');
+ if (sizeBytes > MAX_SCRIPT_SIZE_BYTES) {
+ res.status(400).json({
+ success: false,
+ error: `Script size (${Math.round(sizeBytes / 1024)}KB) exceeds maximum allowed size (${Math.round(MAX_SCRIPT_SIZE_BYTES / 1024)}KB)`,
+ });
+ return;
+ }
+
+ // Log warning if potentially dangerous patterns are detected (non-blocking)
+ const dangerousPatterns = [
+ /rm\s+-rf\s+\/(?!\s*\$)/i, // rm -rf / (not followed by variable)
+ /curl\s+.*\|\s*(?:bash|sh)/i, // curl | bash
+ /wget\s+.*\|\s*(?:bash|sh)/i, // wget | sh
+ ];
+
+ for (const pattern of dangerousPatterns) {
+ if (pattern.test(content)) {
+ logger.warn(
+ `Init script contains potentially dangerous pattern: ${pattern.source}. User responsibility to verify script safety.`
+ );
+ }
+ }
+
+ const scriptPath = getInitScriptPath(projectPath);
+ const automakerDir = path.dirname(scriptPath);
+
+ // Ensure .automaker directory exists
+ await secureFs.mkdir(automakerDir, { recursive: true });
+
+ // Write the script content
+ await secureFs.writeFile(scriptPath, content, 'utf-8');
+
+ logger.info(`Wrote init script to ${scriptPath}`);
+
+ res.json({
+ success: true,
+ path: scriptPath,
+ });
+ } catch (error) {
+ logError(error, 'Write init script failed');
+ res.status(500).json({
+ success: false,
+ error: getErrorMessage(error),
+ });
+ }
+ };
+}
+
+/**
+ * DELETE /init-script - Delete the init script file
+ */
+export function createDeleteInitScriptHandler() {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath } = req.body as { projectPath: string };
+
+ if (!projectPath) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath is required',
+ });
+ return;
+ }
+
+ const scriptPath = getInitScriptPath(projectPath);
+
+ await secureFs.rm(scriptPath, { force: true });
+ logger.info(`Deleted init script at ${scriptPath}`);
+ res.json({
+ success: true,
+ });
+ } catch (error) {
+ logError(error, 'Delete init script failed');
+ res.status(500).json({
+ success: false,
+ error: getErrorMessage(error),
+ });
+ }
+ };
+}
+
+/**
+ * POST /run-init-script - Run (or re-run) the init script for a worktree
+ */
+export function createRunInitScriptHandler(events: EventEmitter) {
+ return async (req: Request, res: Response): Promise => {
+ try {
+ const { projectPath, worktreePath, branch } = req.body as {
+ projectPath: string;
+ worktreePath: string;
+ branch: string;
+ };
+
+ if (!projectPath) {
+ res.status(400).json({
+ success: false,
+ error: 'projectPath is required',
+ });
+ return;
+ }
+
+ if (!worktreePath) {
+ res.status(400).json({
+ success: false,
+ error: 'worktreePath is required',
+ });
+ return;
+ }
+
+ if (!branch) {
+ res.status(400).json({
+ success: false,
+ error: 'branch is required',
+ });
+ return;
+ }
+
+ // Validate branch name to prevent injection via environment variables
+ if (!isValidBranchName(branch)) {
+ res.status(400).json({
+ success: false,
+ error:
+ 'Invalid branch name. Branch names must contain only letters, numbers, dots, hyphens, underscores, and forward slashes.',
+ });
+ return;
+ }
+
+ const scriptPath = getInitScriptPath(projectPath);
+
+ // Check if script exists
+ try {
+ await secureFs.access(scriptPath);
+ } catch {
+ res.status(404).json({
+ success: false,
+ error: 'No init script found. Create one in Settings > Worktrees.',
+ });
+ return;
+ }
+
+ logger.info(`Running init script for branch "${branch}" (forced)`);
+
+ // Run the script asynchronously (non-blocking)
+ forceRunInitScript({
+ projectPath,
+ worktreePath,
+ branch,
+ emitter: events,
+ });
+
+ // Return immediately - progress will be streamed via WebSocket events
+ res.json({
+ success: true,
+ message: 'Init script started',
+ });
+ } catch (error) {
+ logError(error, 'Run init script failed');
+ res.status(500).json({
+ success: false,
+ error: getErrorMessage(error),
+ });
+ }
+ };
+}
diff --git a/apps/server/src/services/init-script-service.ts b/apps/server/src/services/init-script-service.ts
new file mode 100644
index 00000000..7731c5ee
--- /dev/null
+++ b/apps/server/src/services/init-script-service.ts
@@ -0,0 +1,360 @@
+/**
+ * Init Script Service - Executes worktree initialization scripts
+ *
+ * Runs the .automaker/worktree-init.sh script after worktree creation.
+ * Uses Git Bash on Windows for cross-platform shell script compatibility.
+ */
+
+import { spawn } from 'child_process';
+import path from 'path';
+import { createLogger } from '@automaker/utils';
+import { systemPathExists, getShellPaths, findGitBashPath } from '@automaker/platform';
+import { findCommand } from '../lib/cli-detection.js';
+import type { EventEmitter } from '../lib/events.js';
+import { readWorktreeMetadata, writeWorktreeMetadata } from '../lib/worktree-metadata.js';
+import * as secureFs from '../lib/secure-fs.js';
+
+const logger = createLogger('InitScript');
+
+export interface InitScriptOptions {
+ /** Absolute path to the project root */
+ projectPath: string;
+ /** Absolute path to the worktree directory */
+ worktreePath: string;
+ /** Branch name for this worktree */
+ branch: string;
+ /** Event emitter for streaming output */
+ emitter: EventEmitter;
+}
+
+interface ShellCommand {
+ shell: string;
+ args: string[];
+}
+
+/**
+ * Init Script Service
+ *
+ * Handles execution of worktree initialization scripts with cross-platform
+ * shell detection and proper streaming of output via WebSocket events.
+ */
+export class InitScriptService {
+ private cachedShellCommand: ShellCommand | null | undefined = undefined;
+
+ /**
+ * Get the path to the init script for a project
+ */
+ getInitScriptPath(projectPath: string): string {
+ return path.join(projectPath, '.automaker', 'worktree-init.sh');
+ }
+
+ /**
+ * Check if the init script has already been run for a worktree
+ */
+ async hasInitScriptRun(projectPath: string, branch: string): Promise {
+ const metadata = await readWorktreeMetadata(projectPath, branch);
+ return metadata?.initScriptRan === true;
+ }
+
+ /**
+ * Find the appropriate shell for running scripts
+ * Uses findGitBashPath() on Windows to avoid WSL bash, then falls back to PATH
+ */
+ async findShellCommand(): Promise {
+ // Return cached result if available
+ if (this.cachedShellCommand !== undefined) {
+ return this.cachedShellCommand;
+ }
+
+ if (process.platform === 'win32') {
+ // On Windows, prioritize Git Bash over WSL bash (C:\Windows\System32\bash.exe)
+ // WSL bash may not be properly configured and causes ENOENT errors
+
+ // First try known Git Bash installation paths
+ const gitBashPath = await findGitBashPath();
+ if (gitBashPath) {
+ logger.debug(`Found Git Bash at: ${gitBashPath}`);
+ this.cachedShellCommand = { shell: gitBashPath, args: [] };
+ return this.cachedShellCommand;
+ }
+
+ // Fall back to finding bash in PATH, but skip WSL bash
+ const bashInPath = await findCommand(['bash']);
+ if (bashInPath && !bashInPath.toLowerCase().includes('system32')) {
+ logger.debug(`Found bash in PATH at: ${bashInPath}`);
+ this.cachedShellCommand = { shell: bashInPath, args: [] };
+ return this.cachedShellCommand;
+ }
+
+ logger.warn('Git Bash not found. WSL bash was skipped to avoid compatibility issues.');
+ this.cachedShellCommand = null;
+ return null;
+ }
+
+ // Unix-like systems: use getShellPaths() and check existence
+ const shellPaths = getShellPaths();
+ const posixShells = shellPaths.filter(
+ (p) => p.includes('bash') || p === '/bin/sh' || p === '/usr/bin/sh'
+ );
+
+ for (const shellPath of posixShells) {
+ try {
+ if (systemPathExists(shellPath)) {
+ this.cachedShellCommand = { shell: shellPath, args: [] };
+ return this.cachedShellCommand;
+ }
+ } catch {
+ // Path not allowed or doesn't exist, continue
+ }
+ }
+
+ // Ultimate fallback
+ if (systemPathExists('/bin/sh')) {
+ this.cachedShellCommand = { shell: '/bin/sh', args: [] };
+ return this.cachedShellCommand;
+ }
+
+ this.cachedShellCommand = null;
+ return null;
+ }
+
+ /**
+ * Run the worktree initialization script
+ * Non-blocking - returns immediately after spawning
+ */
+ async runInitScript(options: InitScriptOptions): Promise {
+ const { projectPath, worktreePath, branch, emitter } = options;
+
+ const scriptPath = this.getInitScriptPath(projectPath);
+
+ // Check if script exists using secureFs (respects ALLOWED_ROOT_DIRECTORY)
+ try {
+ await secureFs.access(scriptPath);
+ } catch {
+ logger.debug(`No init script found at ${scriptPath}`);
+ return;
+ }
+
+ // Check if already run
+ if (await this.hasInitScriptRun(projectPath, branch)) {
+ logger.info(`Init script already ran for branch "${branch}", skipping`);
+ return;
+ }
+
+ // Get shell command
+ const shellCmd = await this.findShellCommand();
+ if (!shellCmd) {
+ const error =
+ process.platform === 'win32'
+ ? 'Git Bash not found. Please install Git for Windows to run init scripts.'
+ : 'No shell found (/bin/bash or /bin/sh)';
+ logger.error(error);
+
+ // Update metadata with error, preserving existing metadata
+ const existingMetadata = await readWorktreeMetadata(projectPath, branch);
+ await writeWorktreeMetadata(projectPath, branch, {
+ branch,
+ createdAt: existingMetadata?.createdAt || new Date().toISOString(),
+ pr: existingMetadata?.pr,
+ initScriptRan: true,
+ initScriptStatus: 'failed',
+ initScriptError: error,
+ });
+
+ emitter.emit('worktree:init-completed', {
+ projectPath,
+ worktreePath,
+ branch,
+ success: false,
+ error,
+ });
+ return;
+ }
+
+ logger.info(`Running init script for branch "${branch}" in ${worktreePath}`);
+ logger.debug(`Using shell: ${shellCmd.shell}`);
+
+ // Update metadata to mark as running
+ const existingMetadata = await readWorktreeMetadata(projectPath, branch);
+ await writeWorktreeMetadata(projectPath, branch, {
+ branch,
+ createdAt: existingMetadata?.createdAt || new Date().toISOString(),
+ pr: existingMetadata?.pr,
+ initScriptRan: false,
+ initScriptStatus: 'running',
+ });
+
+ // Emit started event
+ emitter.emit('worktree:init-started', {
+ projectPath,
+ worktreePath,
+ branch,
+ });
+
+ // Build safe environment - only pass necessary variables, not all of process.env
+ // This prevents exposure of sensitive credentials like ANTHROPIC_API_KEY
+ const safeEnv: Record = {
+ // Automaker-specific variables
+ AUTOMAKER_PROJECT_PATH: projectPath,
+ AUTOMAKER_WORKTREE_PATH: worktreePath,
+ AUTOMAKER_BRANCH: branch,
+
+ // Essential system variables
+ PATH: process.env.PATH || '',
+ HOME: process.env.HOME || '',
+ USER: process.env.USER || '',
+ TMPDIR: process.env.TMPDIR || process.env.TEMP || process.env.TMP || '/tmp',
+
+ // Shell and locale
+ SHELL: process.env.SHELL || '',
+ LANG: process.env.LANG || 'en_US.UTF-8',
+ LC_ALL: process.env.LC_ALL || '',
+
+ // Force color output even though we're not a TTY
+ FORCE_COLOR: '1',
+ npm_config_color: 'always',
+ CLICOLOR_FORCE: '1',
+
+ // Git configuration
+ GIT_TERMINAL_PROMPT: '0',
+ };
+
+ // Platform-specific additions
+ if (process.platform === 'win32') {
+ safeEnv.USERPROFILE = process.env.USERPROFILE || '';
+ safeEnv.APPDATA = process.env.APPDATA || '';
+ safeEnv.LOCALAPPDATA = process.env.LOCALAPPDATA || '';
+ safeEnv.SystemRoot = process.env.SystemRoot || 'C:\\Windows';
+ safeEnv.TEMP = process.env.TEMP || '';
+ }
+
+ // Spawn the script with safe environment
+ const child = spawn(shellCmd.shell, [...shellCmd.args, scriptPath], {
+ cwd: worktreePath,
+ env: safeEnv,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ // Stream stdout
+ child.stdout?.on('data', (data: Buffer) => {
+ const content = data.toString();
+ emitter.emit('worktree:init-output', {
+ projectPath,
+ branch,
+ type: 'stdout',
+ content,
+ });
+ });
+
+ // Stream stderr
+ child.stderr?.on('data', (data: Buffer) => {
+ const content = data.toString();
+ emitter.emit('worktree:init-output', {
+ projectPath,
+ branch,
+ type: 'stderr',
+ content,
+ });
+ });
+
+ // Handle completion
+ child.on('exit', async (code) => {
+ const success = code === 0;
+ const status = success ? 'success' : 'failed';
+
+ logger.info(`Init script for branch "${branch}" ${status} with exit code ${code}`);
+
+ // Update metadata
+ const metadata = await readWorktreeMetadata(projectPath, branch);
+ await writeWorktreeMetadata(projectPath, branch, {
+ branch,
+ createdAt: metadata?.createdAt || new Date().toISOString(),
+ pr: metadata?.pr,
+ initScriptRan: true,
+ initScriptStatus: status,
+ initScriptError: success ? undefined : `Exit code: ${code}`,
+ });
+
+ // Emit completion event
+ emitter.emit('worktree:init-completed', {
+ projectPath,
+ worktreePath,
+ branch,
+ success,
+ exitCode: code,
+ });
+ });
+
+ child.on('error', async (error) => {
+ logger.error(`Init script error for branch "${branch}":`, error);
+
+ // Update metadata
+ const metadata = await readWorktreeMetadata(projectPath, branch);
+ await writeWorktreeMetadata(projectPath, branch, {
+ branch,
+ createdAt: metadata?.createdAt || new Date().toISOString(),
+ pr: metadata?.pr,
+ initScriptRan: true,
+ initScriptStatus: 'failed',
+ initScriptError: error.message,
+ });
+
+ // Emit completion with error
+ emitter.emit('worktree:init-completed', {
+ projectPath,
+ worktreePath,
+ branch,
+ success: false,
+ error: error.message,
+ });
+ });
+ }
+
+ /**
+ * Force re-run the worktree initialization script
+ * Ignores the initScriptRan flag - useful for testing or re-setup
+ */
+ async forceRunInitScript(options: InitScriptOptions): Promise {
+ const { projectPath, branch } = options;
+
+ // Reset the initScriptRan flag so the script will run
+ const metadata = await readWorktreeMetadata(projectPath, branch);
+ if (metadata) {
+ await writeWorktreeMetadata(projectPath, branch, {
+ ...metadata,
+ initScriptRan: false,
+ initScriptStatus: undefined,
+ initScriptError: undefined,
+ });
+ }
+
+ // Now run the script
+ await this.runInitScript(options);
+ }
+}
+
+// Singleton instance for convenience
+let initScriptService: InitScriptService | null = null;
+
+/**
+ * Get the singleton InitScriptService instance
+ */
+export function getInitScriptService(): InitScriptService {
+ if (!initScriptService) {
+ initScriptService = new InitScriptService();
+ }
+ return initScriptService;
+}
+
+// Export convenience functions that use the singleton
+export const getInitScriptPath = (projectPath: string) =>
+ getInitScriptService().getInitScriptPath(projectPath);
+
+export const hasInitScriptRun = (projectPath: string, branch: string) =>
+ getInitScriptService().hasInitScriptRun(projectPath, branch);
+
+export const runInitScript = (options: InitScriptOptions) =>
+ getInitScriptService().runInitScript(options);
+
+export const forceRunInitScript = (options: InitScriptOptions) =>
+ getInitScriptService().forceRunInitScript(options);
diff --git a/apps/ui/package.json b/apps/ui/package.json
index f5b5aa6e..167734d3 100644
--- a/apps/ui/package.json
+++ b/apps/ui/package.json
@@ -42,6 +42,8 @@
"@automaker/dependency-resolver": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
+ "@codemirror/language": "^6.12.1",
+ "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "6.1.3",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
diff --git a/apps/ui/src/components/ui/ansi-output.tsx b/apps/ui/src/components/ui/ansi-output.tsx
new file mode 100644
index 00000000..83b3c4ab
--- /dev/null
+++ b/apps/ui/src/components/ui/ansi-output.tsx
@@ -0,0 +1,276 @@
+import { useMemo } from 'react';
+import { cn } from '@/lib/utils';
+
+interface AnsiOutputProps {
+ text: string;
+ className?: string;
+}
+
+// ANSI color codes to CSS color mappings
+const ANSI_COLORS: Record = {
+ // Standard colors
+ 30: '#6b7280', // Black (use gray for visibility on dark bg)
+ 31: '#ef4444', // Red
+ 32: '#22c55e', // Green
+ 33: '#eab308', // Yellow
+ 34: '#3b82f6', // Blue
+ 35: '#a855f7', // Magenta
+ 36: '#06b6d4', // Cyan
+ 37: '#d1d5db', // White
+ // Bright colors
+ 90: '#9ca3af', // Bright Black (Gray)
+ 91: '#f87171', // Bright Red
+ 92: '#4ade80', // Bright Green
+ 93: '#facc15', // Bright Yellow
+ 94: '#60a5fa', // Bright Blue
+ 95: '#c084fc', // Bright Magenta
+ 96: '#22d3ee', // Bright Cyan
+ 97: '#ffffff', // Bright White
+};
+
+const ANSI_BG_COLORS: Record = {
+ 40: 'transparent',
+ 41: '#ef4444',
+ 42: '#22c55e',
+ 43: '#eab308',
+ 44: '#3b82f6',
+ 45: '#a855f7',
+ 46: '#06b6d4',
+ 47: '#f3f4f6',
+ // Bright backgrounds
+ 100: '#374151',
+ 101: '#f87171',
+ 102: '#4ade80',
+ 103: '#facc15',
+ 104: '#60a5fa',
+ 105: '#c084fc',
+ 106: '#22d3ee',
+ 107: '#ffffff',
+};
+
+interface TextSegment {
+ text: string;
+ style: {
+ color?: string;
+ backgroundColor?: string;
+ fontWeight?: string;
+ fontStyle?: string;
+ textDecoration?: string;
+ };
+}
+
+/**
+ * Strip hyperlink escape sequences (OSC 8)
+ * Format: ESC]8;;url ESC\ text ESC]8;; ESC\
+ */
+function stripHyperlinks(text: string): string {
+ // Remove OSC 8 hyperlink sequences
+ // eslint-disable-next-line no-control-regex
+ return text.replace(/\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
+}
+
+/**
+ * Strip other OSC sequences (title, etc.)
+ */
+function stripOtherOSC(text: string): string {
+ // Remove OSC sequences (ESC ] ... BEL or ESC ] ... ST)
+ // eslint-disable-next-line no-control-regex
+ return text.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '');
+}
+
+function parseAnsi(text: string): TextSegment[] {
+ // Pre-process: strip hyperlinks and other OSC sequences
+ let processedText = stripHyperlinks(text);
+ processedText = stripOtherOSC(processedText);
+
+ const segments: TextSegment[] = [];
+
+ // Match ANSI escape sequences: ESC[...m (SGR - Select Graphic Rendition)
+ // Also handle ESC[K (erase line) and other CSI sequences by stripping them
+ // The ESC character can be \x1b, \033, \u001b
+ // eslint-disable-next-line no-control-regex
+ const ansiRegex = /\x1b\[([0-9;]*)([a-zA-Z])/g;
+
+ let currentStyle: TextSegment['style'] = {};
+ let lastIndex = 0;
+ let match;
+
+ while ((match = ansiRegex.exec(processedText)) !== null) {
+ // Add text before this escape sequence
+ if (match.index > lastIndex) {
+ const content = processedText.slice(lastIndex, match.index);
+ if (content) {
+ segments.push({ text: content, style: { ...currentStyle } });
+ }
+ }
+
+ const params = match[1];
+ const command = match[2];
+
+ // Only process 'm' command (SGR - graphics/color)
+ // Ignore other commands like K (erase), H (cursor), J (clear), etc.
+ if (command === 'm') {
+ // Parse the escape sequence codes
+ const codes = params ? params.split(';').map((c) => parseInt(c, 10) || 0) : [0];
+
+ for (let i = 0; i < codes.length; i++) {
+ const code = codes[i];
+
+ if (code === 0) {
+ // Reset all attributes
+ currentStyle = {};
+ } else if (code === 1) {
+ // Bold
+ currentStyle.fontWeight = 'bold';
+ } else if (code === 2) {
+ // Dim/faint
+ currentStyle.color = 'var(--muted-foreground)';
+ } else if (code === 3) {
+ // Italic
+ currentStyle.fontStyle = 'italic';
+ } else if (code === 4) {
+ // Underline
+ currentStyle.textDecoration = 'underline';
+ } else if (code === 22) {
+ // Normal intensity (not bold, not dim)
+ currentStyle.fontWeight = undefined;
+ } else if (code === 23) {
+ // Not italic
+ currentStyle.fontStyle = undefined;
+ } else if (code === 24) {
+ // Not underlined
+ currentStyle.textDecoration = undefined;
+ } else if (code === 38) {
+ // Extended foreground color
+ if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
+ // 256 color mode: 38;5;n
+ const colorIndex = codes[i + 2];
+ currentStyle.color = get256Color(colorIndex);
+ i += 2;
+ } else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
+ // RGB mode: 38;2;r;g;b
+ const r = codes[i + 2];
+ const g = codes[i + 3];
+ const b = codes[i + 4];
+ currentStyle.color = `rgb(${r}, ${g}, ${b})`;
+ i += 4;
+ }
+ } else if (code === 48) {
+ // Extended background color
+ if (codes[i + 1] === 5 && codes[i + 2] !== undefined) {
+ // 256 color mode: 48;5;n
+ const colorIndex = codes[i + 2];
+ currentStyle.backgroundColor = get256Color(colorIndex);
+ i += 2;
+ } else if (codes[i + 1] === 2 && codes[i + 4] !== undefined) {
+ // RGB mode: 48;2;r;g;b
+ const r = codes[i + 2];
+ const g = codes[i + 3];
+ const b = codes[i + 4];
+ currentStyle.backgroundColor = `rgb(${r}, ${g}, ${b})`;
+ i += 4;
+ }
+ } else if (ANSI_COLORS[code]) {
+ // Standard foreground color (30-37, 90-97)
+ currentStyle.color = ANSI_COLORS[code];
+ } else if (ANSI_BG_COLORS[code]) {
+ // Standard background color (40-47, 100-107)
+ currentStyle.backgroundColor = ANSI_BG_COLORS[code];
+ } else if (code === 39) {
+ // Default foreground
+ currentStyle.color = undefined;
+ } else if (code === 49) {
+ // Default background
+ currentStyle.backgroundColor = undefined;
+ }
+ }
+ }
+
+ lastIndex = match.index + match[0].length;
+ }
+
+ // Add remaining text after last escape sequence
+ if (lastIndex < processedText.length) {
+ const content = processedText.slice(lastIndex);
+ if (content) {
+ segments.push({ text: content, style: { ...currentStyle } });
+ }
+ }
+
+ // If no segments were created (no ANSI codes), return the whole text
+ if (segments.length === 0 && processedText) {
+ segments.push({ text: processedText, style: {} });
+ }
+
+ return segments;
+}
+
+/**
+ * Convert 256-color palette index to CSS color
+ */
+function get256Color(index: number): string {
+ // 0-15: Standard colors
+ if (index < 16) {
+ const standardColors = [
+ '#000000',
+ '#cd0000',
+ '#00cd00',
+ '#cdcd00',
+ '#0000ee',
+ '#cd00cd',
+ '#00cdcd',
+ '#e5e5e5',
+ '#7f7f7f',
+ '#ff0000',
+ '#00ff00',
+ '#ffff00',
+ '#5c5cff',
+ '#ff00ff',
+ '#00ffff',
+ '#ffffff',
+ ];
+ return standardColors[index];
+ }
+
+ // 16-231: 6x6x6 color cube
+ if (index < 232) {
+ const n = index - 16;
+ const b = n % 6;
+ const g = Math.floor(n / 6) % 6;
+ const r = Math.floor(n / 36);
+ const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40);
+ return `rgb(${toHex(r)}, ${toHex(g)}, ${toHex(b)})`;
+ }
+
+ // 232-255: Grayscale
+ const gray = 8 + (index - 232) * 10;
+ return `rgb(${gray}, ${gray}, ${gray})`;
+}
+
+export function AnsiOutput({ text, className }: AnsiOutputProps) {
+ const segments = useMemo(() => parseAnsi(text), [text]);
+
+ return (
+
+ {segments.map((segment, index) => (
+
+ {segment.text}
+
+ ))}
+
+ );
+}
diff --git a/apps/ui/src/components/ui/shell-syntax-editor.tsx b/apps/ui/src/components/ui/shell-syntax-editor.tsx
new file mode 100644
index 00000000..159123c4
--- /dev/null
+++ b/apps/ui/src/components/ui/shell-syntax-editor.tsx
@@ -0,0 +1,142 @@
+import CodeMirror from '@uiw/react-codemirror';
+import { StreamLanguage } from '@codemirror/language';
+import { shell } from '@codemirror/legacy-modes/mode/shell';
+import { EditorView } from '@codemirror/view';
+import { Extension } from '@codemirror/state';
+import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
+import { tags as t } from '@lezer/highlight';
+import { cn } from '@/lib/utils';
+
+interface ShellSyntaxEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ className?: string;
+ minHeight?: string;
+ maxHeight?: string;
+ 'data-testid'?: string;
+}
+
+// Syntax highlighting using CSS variables for theme compatibility
+const syntaxColors = HighlightStyle.define([
+ // Keywords (if, then, else, fi, for, while, do, done, case, esac, etc.)
+ { tag: t.keyword, color: 'var(--chart-4, oklch(0.7 0.15 280))' },
+
+ // Strings (single and double quoted)
+ { tag: t.string, color: 'var(--chart-1, oklch(0.646 0.222 41.116))' },
+
+ // Comments
+ { tag: t.comment, color: 'var(--muted-foreground)', fontStyle: 'italic' },
+
+ // Variables ($VAR, ${VAR})
+ { tag: t.variableName, color: 'var(--chart-2, oklch(0.6 0.118 184.704))' },
+
+ // Operators
+ { tag: t.operator, color: 'var(--muted-foreground)' },
+
+ // Numbers
+ { tag: t.number, color: 'var(--chart-3, oklch(0.7 0.15 150))' },
+
+ // Function names / commands
+ { tag: t.function(t.variableName), color: 'var(--primary)' },
+ { tag: t.attributeName, color: 'var(--chart-5, oklch(0.65 0.2 30))' },
+
+ // Default text
+ { tag: t.content, color: 'var(--foreground)' },
+]);
+
+// Editor theme using CSS variables
+const editorTheme = EditorView.theme({
+ '&': {
+ height: '100%',
+ fontSize: '0.875rem',
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
+ backgroundColor: 'transparent',
+ color: 'var(--foreground)',
+ },
+ '.cm-scroller': {
+ overflow: 'auto',
+ fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
+ },
+ '.cm-content': {
+ padding: '0.75rem',
+ minHeight: '100%',
+ caretColor: 'var(--primary)',
+ },
+ '.cm-cursor, .cm-dropCursor': {
+ borderLeftColor: 'var(--primary)',
+ },
+ '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
+ backgroundColor: 'oklch(0.55 0.25 265 / 0.3)',
+ },
+ '.cm-activeLine': {
+ backgroundColor: 'var(--accent)',
+ opacity: '0.3',
+ },
+ '.cm-line': {
+ padding: '0 0.25rem',
+ },
+ '&.cm-focused': {
+ outline: 'none',
+ },
+ '.cm-gutters': {
+ backgroundColor: 'transparent',
+ color: 'var(--muted-foreground)',
+ border: 'none',
+ paddingRight: '0.5rem',
+ },
+ '.cm-lineNumbers .cm-gutterElement': {
+ minWidth: '2rem',
+ textAlign: 'right',
+ paddingRight: '0.5rem',
+ },
+ '.cm-placeholder': {
+ color: 'var(--muted-foreground)',
+ fontStyle: 'italic',
+ },
+});
+
+// Combine all extensions
+const extensions: Extension[] = [
+ StreamLanguage.define(shell),
+ syntaxHighlighting(syntaxColors),
+ editorTheme,
+];
+
+export function ShellSyntaxEditor({
+ value,
+ onChange,
+ placeholder,
+ className,
+ minHeight = '200px',
+ maxHeight,
+ 'data-testid': testId,
+}: ShellSyntaxEditorProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx
index 2ad6560c..30cd4db3 100644
--- a/apps/ui/src/components/views/board-view.tsx
+++ b/apps/ui/src/components/views/board-view.tsx
@@ -75,6 +75,8 @@ import {
} from './board-view/hooks';
import { SelectionActionBar } from './board-view/components';
import { MassEditDialog } from './board-view/dialogs';
+import { InitScriptIndicator } from './board-view/init-script-indicator';
+import { useInitScriptEvents } from '@/hooks/use-init-script-events';
// Stable empty array to avoid infinite loop in selector
const EMPTY_WORKTREES: ReturnType['getWorktrees']> = [];
@@ -99,6 +101,8 @@ export function BoardView() {
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
+ planUseSelectedWorktreeBranch,
+ addFeatureUseSelectedWorktreeBranch,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
@@ -107,6 +111,12 @@ export function BoardView() {
const pipelineConfigByProject = useAppStore((state) => state.pipelineConfigByProject);
// Subscribe to worktreePanelVisibleByProject to trigger re-renders when it changes
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
+ // Subscribe to showInitScriptIndicatorByProject to trigger re-renders when it changes
+ const showInitScriptIndicatorByProject = useAppStore(
+ (state) => state.showInitScriptIndicatorByProject
+ );
+ const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
+ const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
const shortcuts = useKeyboardShortcutsConfig();
const {
features: hookFeatures,
@@ -252,6 +262,9 @@ export function BoardView() {
// Window state hook for compact dialog mode
const { isMaximized } = useWindowState();
+ // Init script events hook - subscribe to worktree init script events
+ useInitScriptEvents(currentProject?.path ?? null);
+
// Keyboard shortcuts hook will be initialized after actions hook
// Prevent hydration issues
@@ -1361,6 +1374,14 @@ export function BoardView() {
isMaximized={isMaximized}
parentFeature={spawnParentFeature}
allFeatures={hookFeatures}
+ // When setting is enabled and a non-main worktree is selected, pass its branch to default to 'custom' work mode
+ selectedNonMainWorktreeBranch={
+ addFeatureUseSelectedWorktreeBranch && currentWorktreePath !== null
+ ? currentWorktreeBranch || undefined
+ : undefined
+ }
+ // When the worktree setting is disabled, force 'current' branch mode
+ forceCurrentBranchMode={!addFeatureUseSelectedWorktreeBranch}
/>
{/* Edit Feature Dialog */}
@@ -1440,6 +1461,7 @@ export function BoardView() {
setPendingPlanResult={setPendingBacklogPlan}
isGeneratingPlan={isGeneratingPlan}
setIsGeneratingPlan={setIsGeneratingPlan}
+ currentBranch={planUseSelectedWorktreeBranch ? selectedWorktreeBranch : undefined}
/>
{/* Plan Approval Dialog */}
@@ -1507,6 +1529,7 @@ export function BoardView() {
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0
}
+ defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => {
@@ -1574,6 +1597,11 @@ export function BoardView() {
setSelectedWorktreeForAction(null);
}}
/>
+
+ {/* Init Script Indicator - floating overlay for worktree init script status */}
+ {getShowInitScriptIndicator(currentProject.path) && (
+
+ )}
);
}
diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx
index 5a9b7302..cfaa8a27 100644
--- a/apps/ui/src/components/views/board-view/board-header.tsx
+++ b/apps/ui/src/components/views/board-view/board-header.tsx
@@ -9,6 +9,8 @@ import { UsagePopover } from '@/components/usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
+import { WorktreeSettingsDialog } from './dialogs/worktree-settings-dialog';
+import { PlanSettingsDialog } from './dialogs/plan-settings-dialog';
import { getHttpApiClient } from '@/lib/http-api-client';
import { BoardSearchBar } from './board-search-bar';
import { BoardControls } from './board-controls';
@@ -55,10 +57,22 @@ export function BoardHeader({
completedCount,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = useState(false);
+ const [showWorktreeSettings, setShowWorktreeSettings] = useState(false);
+ const [showPlanSettings, setShowPlanSettings] = useState(false);
const apiKeys = useAppStore((state) => state.apiKeys);
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
const skipVerificationInAutoMode = useAppStore((state) => state.skipVerificationInAutoMode);
const setSkipVerificationInAutoMode = useAppStore((state) => state.setSkipVerificationInAutoMode);
+ const planUseSelectedWorktreeBranch = useAppStore((state) => state.planUseSelectedWorktreeBranch);
+ const setPlanUseSelectedWorktreeBranch = useAppStore(
+ (state) => state.setPlanUseSelectedWorktreeBranch
+ );
+ const addFeatureUseSelectedWorktreeBranch = useAppStore(
+ (state) => state.addFeatureUseSelectedWorktreeBranch
+ );
+ const setAddFeatureUseSelectedWorktreeBranch = useAppStore(
+ (state) => state.setAddFeatureUseSelectedWorktreeBranch
+ );
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
// Worktree panel visibility (per-project)
@@ -132,9 +146,25 @@ export function BoardHeader({
onCheckedChange={handleWorktreePanelToggle}
data-testid="worktrees-toggle"
/>
+
)}
+ {/* Worktree Settings Dialog */}
+
+
{/* Concurrency Control - only show after mount to prevent hydration issues */}
{isMounted && (
@@ -209,15 +239,33 @@ export function BoardHeader({
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
-
+ {/* Plan Button with Settings */}
+
+
+
+
+
+ {/* Plan Settings Dialog */}
+
);
diff --git a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx
index 797b64b9..736f3c40 100644
--- a/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/add-feature-dialog.tsx
@@ -56,6 +56,32 @@ import {
const logger = createLogger('AddFeatureDialog');
+/**
+ * Determines the default work mode based on global settings and current worktree selection.
+ *
+ * Priority:
+ * 1. If forceCurrentBranchMode is true, always defaults to 'current' (work on current branch)
+ * 2. If a non-main worktree is selected in the board header, defaults to 'custom' (use that branch)
+ * 3. If useWorktrees global setting is enabled, defaults to 'auto' (automatic worktree creation)
+ * 4. Otherwise, defaults to 'current' (work on current branch without isolation)
+ */
+const getDefaultWorkMode = (
+ useWorktrees: boolean,
+ selectedNonMainWorktreeBranch?: string,
+ forceCurrentBranchMode?: boolean
+): WorkMode => {
+ // If force current branch mode is enabled (worktree setting is off), always use 'current'
+ if (forceCurrentBranchMode) {
+ return 'current';
+ }
+ // If a non-main worktree is selected, default to 'custom' mode with that branch
+ if (selectedNonMainWorktreeBranch) {
+ return 'custom';
+ }
+ // Otherwise, respect the global worktree setting
+ return useWorktrees ? 'auto' : 'current';
+};
+
type FeatureData = {
title: string;
category: string;
@@ -89,6 +115,16 @@ interface AddFeatureDialogProps {
isMaximized: boolean;
parentFeature?: Feature | null;
allFeatures?: Feature[];
+ /**
+ * When a non-main worktree is selected in the board header, this will be set to that worktree's branch.
+ * When set, the dialog will default to 'custom' work mode with this branch pre-filled.
+ */
+ selectedNonMainWorktreeBranch?: string;
+ /**
+ * When true, forces the dialog to default to 'current' work mode (work on current branch).
+ * This is used when the "Use selected worktree branch" setting is disabled.
+ */
+ forceCurrentBranchMode?: boolean;
}
/**
@@ -112,6 +148,8 @@ export function AddFeatureDialog({
isMaximized,
parentFeature = null,
allFeatures = [],
+ selectedNonMainWorktreeBranch,
+ forceCurrentBranchMode,
}: AddFeatureDialogProps) {
const isSpawnMode = !!parentFeature;
const [workMode, setWorkMode] = useState('current');
@@ -149,7 +187,7 @@ export function AddFeatureDialog({
const [selectedAncestorIds, setSelectedAncestorIds] = useState>(new Set());
// Get defaults from store
- const { defaultPlanningMode, defaultRequirePlanApproval } = useAppStore();
+ const { defaultPlanningMode, defaultRequirePlanApproval, useWorktrees } = useAppStore();
// Track previous open state to detect when dialog opens
const wasOpenRef = useRef(false);
@@ -161,8 +199,12 @@ export function AddFeatureDialog({
if (justOpened) {
setSkipTests(defaultSkipTests);
- setBranchName(defaultBranch || '');
- setWorkMode('current');
+ // When a non-main worktree is selected, use its branch name for custom mode
+ // Otherwise, use the default branch
+ setBranchName(selectedNonMainWorktreeBranch || defaultBranch || '');
+ setWorkMode(
+ getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
+ );
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setModelEntry({ model: 'opus' });
@@ -186,6 +228,9 @@ export function AddFeatureDialog({
defaultBranch,
defaultPlanningMode,
defaultRequirePlanApproval,
+ useWorktrees,
+ selectedNonMainWorktreeBranch,
+ forceCurrentBranchMode,
parentFeature,
allFeatures,
]);
@@ -270,10 +315,13 @@ export function AddFeatureDialog({
setImagePaths([]);
setTextFilePaths([]);
setSkipTests(defaultSkipTests);
- setBranchName('');
+ // When a non-main worktree is selected, use its branch name for custom mode
+ setBranchName(selectedNonMainWorktreeBranch || '');
setPriority(2);
setModelEntry({ model: 'opus' });
- setWorkMode('current');
+ setWorkMode(
+ getDefaultWorkMode(useWorktrees, selectedNonMainWorktreeBranch, forceCurrentBranchMode)
+ );
setPlanningMode(defaultPlanningMode);
setRequirePlanApproval(defaultRequirePlanApproval);
setPreviewMap(new Map());
diff --git a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx
index 3579a48b..ee78f153 100644
--- a/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/backlog-plan-dialog.tsx
@@ -63,6 +63,8 @@ interface BacklogPlanDialogProps {
setPendingPlanResult: (result: BacklogPlanResult | null) => void;
isGeneratingPlan: boolean;
setIsGeneratingPlan: (generating: boolean) => void;
+ // Branch to use for created features (defaults to main if not provided)
+ currentBranch?: string;
}
type DialogMode = 'input' | 'review' | 'applying';
@@ -76,6 +78,7 @@ export function BacklogPlanDialog({
setPendingPlanResult,
isGeneratingPlan,
setIsGeneratingPlan,
+ currentBranch,
}: BacklogPlanDialogProps) {
const [mode, setMode] = useState('input');
const [prompt, setPrompt] = useState('');
@@ -167,7 +170,7 @@ export function BacklogPlanDialog({
}) || [],
};
- const result = await api.backlogPlan.apply(projectPath, filteredPlanResult);
+ const result = await api.backlogPlan.apply(projectPath, filteredPlanResult, currentBranch);
if (result.success) {
toast.success(`Applied ${result.appliedChanges?.length || 0} changes`);
setPendingPlanResult(null);
@@ -184,6 +187,7 @@ export function BacklogPlanDialog({
setPendingPlanResult,
onPlanApplied,
onClose,
+ currentBranch,
]);
const handleDiscard = useCallback(() => {
diff --git a/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx
index 584ed622..8a675069 100644
--- a/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/create-worktree-dialog.tsx
@@ -10,10 +10,73 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
-import { GitBranch, Loader2 } from 'lucide-react';
+import { GitBranch, Loader2, AlertCircle } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
import { toast } from 'sonner';
+/**
+ * Parse git/worktree error messages and return user-friendly versions
+ */
+function parseWorktreeError(error: string): { title: string; description?: string } {
+ const errorLower = error.toLowerCase();
+
+ // Worktree already exists
+ if (errorLower.includes('already exists') && errorLower.includes('worktree')) {
+ return {
+ title: 'A worktree with this name already exists',
+ description: 'Try a different branch name or delete the existing worktree first.',
+ };
+ }
+
+ // Branch already checked out in another worktree
+ if (
+ errorLower.includes('already checked out') ||
+ errorLower.includes('is already used by worktree')
+ ) {
+ return {
+ title: 'This branch is already in use',
+ description: 'The branch is checked out in another worktree. Use a different branch name.',
+ };
+ }
+
+ // Branch name conflicts with existing branch
+ if (errorLower.includes('already exists') && errorLower.includes('branch')) {
+ return {
+ title: 'A branch with this name already exists',
+ description: 'The worktree will use the existing branch, or try a different name.',
+ };
+ }
+
+ // Not a git repository
+ if (errorLower.includes('not a git repository')) {
+ return {
+ title: 'Not a git repository',
+ description: 'Initialize git in this project first with "git init".',
+ };
+ }
+
+ // Lock file exists (another git operation in progress)
+ if (errorLower.includes('.lock') || errorLower.includes('lock file')) {
+ return {
+ title: 'Another git operation is in progress',
+ description: 'Wait for it to complete or remove stale lock files.',
+ };
+ }
+
+ // Permission denied
+ if (errorLower.includes('permission denied') || errorLower.includes('access denied')) {
+ return {
+ title: 'Permission denied',
+ description: 'Check file permissions for the project directory.',
+ };
+ }
+
+ // Default: return original error but cleaned up
+ return {
+ title: error.replace(/^(fatal|error):\s*/i, '').split('\n')[0],
+ };
+}
+
interface CreatedWorktreeInfo {
path: string;
branch: string;
@@ -34,20 +97,21 @@ export function CreateWorktreeDialog({
}: CreateWorktreeDialogProps) {
const [branchName, setBranchName] = useState('');
const [isLoading, setIsLoading] = useState(false);
- const [error, setError] = useState(null);
+ const [error, setError] = useState<{ title: string; description?: string } | null>(null);
const handleCreate = async () => {
if (!branchName.trim()) {
- setError('Branch name is required');
+ setError({ title: 'Branch name is required' });
return;
}
// Validate branch name (git-compatible)
const validBranchRegex = /^[a-zA-Z0-9._/-]+$/;
if (!validBranchRegex.test(branchName)) {
- setError(
- 'Invalid branch name. Use only letters, numbers, dots, underscores, hyphens, and slashes.'
- );
+ setError({
+ title: 'Invalid branch name',
+ description: 'Use only letters, numbers, dots, underscores, hyphens, and slashes.',
+ });
return;
}
@@ -57,7 +121,7 @@ export function CreateWorktreeDialog({
try {
const api = getElectronAPI();
if (!api?.worktree?.create) {
- setError('Worktree API not available');
+ setError({ title: 'Worktree API not available' });
return;
}
const result = await api.worktree.create(projectPath, branchName);
@@ -70,10 +134,12 @@ export function CreateWorktreeDialog({
onOpenChange(false);
setBranchName('');
} else {
- setError(result.error || 'Failed to create worktree');
+ setError(parseWorktreeError(result.error || 'Failed to create worktree'));
}
} catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to create worktree');
+ setError(
+ parseWorktreeError(err instanceof Error ? err.message : 'Failed to create worktree')
+ );
} finally {
setIsLoading(false);
}
@@ -114,7 +180,17 @@ export function CreateWorktreeDialog({
className="font-mono text-sm"
autoFocus
/>
- {error && {error}
}
+ {error && (
+
+
+
+
{error.title}
+ {error.description && (
+
{error.description}
+ )}
+
+
+ )}
diff --git a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx
index 3c45c014..718bef0c 100644
--- a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx
+++ b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
@@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps {
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
/** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number;
+ /** Default value for the "delete branch" checkbox */
+ defaultDeleteBranch?: boolean;
}
export function DeleteWorktreeDialog({
@@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({
worktree,
onDeleted,
affectedFeatureCount = 0,
+ defaultDeleteBranch = false,
}: DeleteWorktreeDialogProps) {
- const [deleteBranch, setDeleteBranch] = useState(false);
+ const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
const [isLoading, setIsLoading] = useState(false);
+ // Reset deleteBranch to default when dialog opens
+ useEffect(() => {
+ if (open) {
+ setDeleteBranch(defaultDeleteBranch);
+ }
+ }, [open, defaultDeleteBranch]);
+
const handleDelete = async () => {
if (!worktree) return;
diff --git a/apps/ui/src/components/views/board-view/dialogs/plan-settings-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/plan-settings-dialog.tsx
new file mode 100644
index 00000000..bd42cb1a
--- /dev/null
+++ b/apps/ui/src/components/views/board-view/dialogs/plan-settings-dialog.tsx
@@ -0,0 +1,67 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { GitBranch, Settings2 } from 'lucide-react';
+
+interface PlanSettingsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ planUseSelectedWorktreeBranch: boolean;
+ onPlanUseSelectedWorktreeBranchChange: (value: boolean) => void;
+}
+
+export function PlanSettingsDialog({
+ open,
+ onOpenChange,
+ planUseSelectedWorktreeBranch,
+ onPlanUseSelectedWorktreeBranchChange,
+}: PlanSettingsDialogProps) {
+ return (
+
+ );
+}
diff --git a/apps/ui/src/components/views/board-view/dialogs/worktree-settings-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/worktree-settings-dialog.tsx
new file mode 100644
index 00000000..da7cb134
--- /dev/null
+++ b/apps/ui/src/components/views/board-view/dialogs/worktree-settings-dialog.tsx
@@ -0,0 +1,67 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Label } from '@/components/ui/label';
+import { Switch } from '@/components/ui/switch';
+import { GitBranch, Settings2 } from 'lucide-react';
+
+interface WorktreeSettingsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ addFeatureUseSelectedWorktreeBranch: boolean;
+ onAddFeatureUseSelectedWorktreeBranchChange: (value: boolean) => void;
+}
+
+export function WorktreeSettingsDialog({
+ open,
+ onOpenChange,
+ addFeatureUseSelectedWorktreeBranch,
+ onAddFeatureUseSelectedWorktreeBranchChange,
+}: WorktreeSettingsDialogProps) {
+ return (
+
+ );
+}
diff --git a/apps/ui/src/components/views/board-view/init-script-indicator.tsx b/apps/ui/src/components/views/board-view/init-script-indicator.tsx
new file mode 100644
index 00000000..33298394
--- /dev/null
+++ b/apps/ui/src/components/views/board-view/init-script-indicator.tsx
@@ -0,0 +1,209 @@
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { useAppStore, type InitScriptState } from '@/store/app-store';
+import { AnsiOutput } from '@/components/ui/ansi-output';
+
+interface InitScriptIndicatorProps {
+ projectPath: string;
+}
+
+interface SingleIndicatorProps {
+ stateKey: string;
+ state: InitScriptState;
+ onDismiss: (key: string) => void;
+ isOnlyOne: boolean; // Whether this is the only indicator shown
+ autoDismiss: boolean; // Whether to auto-dismiss after completion
+}
+
+function SingleIndicator({
+ stateKey,
+ state,
+ onDismiss,
+ isOnlyOne,
+ autoDismiss,
+}: SingleIndicatorProps) {
+ const [showLogs, setShowLogs] = useState(false);
+ const logsEndRef = useRef
(null);
+
+ const { status, output, branch, error } = state;
+
+ // Auto-scroll to bottom when new output arrives
+ useEffect(() => {
+ if (showLogs && logsEndRef.current) {
+ logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
+ }
+ }, [output, showLogs]);
+
+ // Auto-expand logs when script starts (only if it's the only one or running)
+ useEffect(() => {
+ if (status === 'running' && isOnlyOne) {
+ setShowLogs(true);
+ }
+ }, [status, isOnlyOne]);
+
+ // Auto-dismiss after completion (5 seconds)
+ useEffect(() => {
+ if (autoDismiss && (status === 'success' || status === 'failed')) {
+ const timer = setTimeout(() => {
+ onDismiss(stateKey);
+ }, 5000);
+ return () => clearTimeout(timer);
+ }
+ }, [status, autoDismiss, stateKey, onDismiss]);
+
+ if (status === 'idle') return null;
+
+ return (
+
+ {/* Header */}
+
+
+ {status === 'running' && }
+ {status === 'success' && }
+ {status === 'failed' && }
+
+ Init Script{' '}
+ {status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
+
+
+
+
+ {status !== 'running' && (
+
+ )}
+
+
+
+ {/* Branch info */}
+
+
+ Branch: {branch}
+
+
+ {/* Logs (collapsible) */}
+ {showLogs && (
+
+
+ {output.length > 0 ? (
+
+ ) : (
+
+ {status === 'running' ? 'Waiting for output...' : 'No output'}
+
+ )}
+ {error &&
Error: {error}
}
+
+
+
+ )}
+
+ {/* Status bar for completed states */}
+ {status !== 'running' && (
+
+ {status === 'success'
+ ? 'Initialization completed successfully'
+ : 'Initialization failed - worktree is still usable'}
+
+ )}
+
+ );
+}
+
+export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
+ const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject);
+ const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
+ const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
+ const [dismissedKeys, setDismissedKeys] = useState>(new Set());
+
+ // Get auto-dismiss setting
+ const autoDismiss = getAutoDismissInitScriptIndicator(projectPath);
+
+ // Get all init script states for this project
+ const allStates = getInitScriptStatesForProject(projectPath);
+
+ // Filter out dismissed and idle states
+ const activeStates = allStates.filter(
+ ({ key, state }) => !dismissedKeys.has(key) && state.status !== 'idle'
+ );
+
+ // Reset dismissed keys when a new script starts for a branch
+ useEffect(() => {
+ const runningKeys = allStates
+ .filter(({ state }) => state.status === 'running')
+ .map(({ key }) => key);
+
+ if (runningKeys.length > 0) {
+ setDismissedKeys((prev) => {
+ const newSet = new Set(prev);
+ runningKeys.forEach((key) => newSet.delete(key));
+ return newSet;
+ });
+ }
+ }, [allStates]);
+
+ const handleDismiss = useCallback(
+ (key: string) => {
+ setDismissedKeys((prev) => new Set(prev).add(key));
+ // Extract branch from key (format: "projectPath::branch")
+ const branch = key.split('::')[1];
+ if (branch) {
+ // Clear state after a delay to allow for future scripts
+ setTimeout(() => {
+ clearInitScriptState(projectPath, branch);
+ }, 100);
+ }
+ },
+ [projectPath, clearInitScriptState]
+ );
+
+ if (activeStates.length === 0) return null;
+
+ return (
+
+ {activeStates.map(({ key, state }) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx
index b94faed7..c7d8f26b 100644
--- a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx
+++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-actions-dropdown.tsx
@@ -23,6 +23,7 @@ import {
MessageSquare,
GitMerge,
AlertCircle,
+ RefreshCw,
Copy,
} from 'lucide-react';
import { toast } from 'sonner';
@@ -55,6 +56,8 @@ interface WorktreeActionsDropdownProps {
onStartDevServer: (worktree: WorktreeInfo) => void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
+ onRunInitScript: (worktree: WorktreeInfo) => void;
+ hasInitScript: boolean;
}
export function WorktreeActionsDropdown({
@@ -80,6 +83,8 @@ export function WorktreeActionsDropdown({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
+ onRunInitScript,
+ hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
const { editors } = useAvailableEditors();
@@ -266,6 +271,12 @@ export function WorktreeActionsDropdown({
)}
+ {!worktree.isMain && hasInitScript && (
+ onRunInitScript(worktree)} className="text-xs">
+
+ Re-run Init Script
+
+ )}
{worktree.hasChanges && (
void;
onStopDevServer: (worktree: WorktreeInfo) => void;
onOpenDevServerUrl: (worktree: WorktreeInfo) => void;
+ onRunInitScript: (worktree: WorktreeInfo) => void;
+ hasInitScript: boolean;
}
export function WorktreeTab({
@@ -85,6 +87,8 @@ export function WorktreeTab({
onStartDevServer,
onStopDevServer,
onOpenDevServerUrl,
+ onRunInitScript,
+ hasInitScript,
}: WorktreeTabProps) {
let prBadge: JSX.Element | null = null;
if (worktree.pr) {
@@ -333,6 +337,8 @@ export function WorktreeTab({
onStartDevServer={onStartDevServer}
onStopDevServer={onStopDevServer}
onOpenDevServerUrl={onOpenDevServerUrl}
+ onRunInitScript={onRunInitScript}
+ hasInitScript={hasInitScript}
/>
);
diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx
index 034f9d2a..a9f2431e 100644
--- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx
+++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx
@@ -1,7 +1,9 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, useCallback, useState } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
+import { toast } from 'sonner';
+import { getHttpApiClient } from '@/lib/http-api-client';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -79,6 +81,28 @@ export function WorktreePanel({
features,
});
+ // Track whether init script exists for the project
+ const [hasInitScript, setHasInitScript] = useState(false);
+
+ useEffect(() => {
+ if (!projectPath) {
+ setHasInitScript(false);
+ return;
+ }
+
+ const checkInitScript = async () => {
+ try {
+ const api = getHttpApiClient();
+ const result = await api.worktree.getInitScript(projectPath);
+ setHasInitScript(result.success && result.exists);
+ } catch {
+ setHasInitScript(false);
+ }
+ };
+
+ checkInitScript();
+ }, [projectPath]);
+
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders
const intervalRef = useRef(null);
@@ -113,6 +137,33 @@ export function WorktreePanel({
}
};
+ const handleRunInitScript = useCallback(
+ async (worktree: WorktreeInfo) => {
+ if (!projectPath) return;
+
+ try {
+ const api = getHttpApiClient();
+ const result = await api.worktree.runInitScript(
+ projectPath,
+ worktree.path,
+ worktree.branch
+ );
+
+ if (!result.success) {
+ toast.error('Failed to run init script', {
+ description: result.error,
+ });
+ }
+ // Success feedback will come via WebSocket events (init-started, init-output, init-completed)
+ } catch (error) {
+ toast.error('Failed to run init script', {
+ description: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ },
+ [projectPath]
+ );
+
const mainWorktree = worktrees.find((w) => w.isMain);
const nonMainWorktrees = worktrees.filter((w) => !w.isMain);
@@ -162,6 +213,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
+ onRunInitScript={handleRunInitScript}
+ hasInitScript={hasInitScript}
/>
)}
@@ -216,6 +269,8 @@ export function WorktreePanel({
onStartDevServer={handleStartDevServer}
onStopDevServer={handleStopDevServer}
onOpenDevServerUrl={handleOpenDevServerUrl}
+ onRunInitScript={handleRunInitScript}
+ hasInitScript={hasInitScript}
/>
);
})}
diff --git a/apps/ui/src/components/views/settings-view.tsx b/apps/ui/src/components/views/settings-view.tsx
index 27cc7703..aa6a8a84 100644
--- a/apps/ui/src/components/views/settings-view.tsx
+++ b/apps/ui/src/components/views/settings-view.tsx
@@ -15,6 +15,7 @@ import { TerminalSection } from './settings-view/terminal/terminal-section';
import { AudioSection } from './settings-view/audio/audio-section';
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
+import { WorktreesSection } from './settings-view/worktrees';
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
import { AccountSection } from './settings-view/account';
import { SecuritySection } from './settings-view/security';
@@ -149,17 +150,19 @@ export function SettingsView() {
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
- useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
- onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
/>
);
+ case 'worktrees':
+ return (
+
+ );
case 'account':
return ;
case 'security':
diff --git a/apps/ui/src/components/views/settings-view/config/navigation.ts b/apps/ui/src/components/views/settings-view/config/navigation.ts
index f7f2b9f6..f63d0494 100644
--- a/apps/ui/src/components/views/settings-view/config/navigation.ts
+++ b/apps/ui/src/components/views/settings-view/config/navigation.ts
@@ -14,6 +14,8 @@ import {
MessageSquareText,
User,
Shield,
+ Cpu,
+ GitBranch,
} from 'lucide-react';
import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon';
import type { SettingsViewId } from '../hooks/use-settings-view';
@@ -37,6 +39,7 @@ export const GLOBAL_NAV_GROUPS: NavigationGroup[] = [
items: [
{ id: 'model-defaults', label: 'Model Defaults', icon: Workflow },
{ id: 'defaults', label: 'Feature Defaults', icon: FlaskConical },
+ { id: 'worktrees', label: 'Worktrees', icon: GitBranch },
{ id: 'prompts', label: 'Prompt Customization', icon: MessageSquareText },
{ id: 'api-keys', label: 'API Keys', icon: Key },
{
diff --git a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx
index a0f9ab36..c3b4e9ae 100644
--- a/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx
+++ b/apps/ui/src/components/views/settings-view/feature-defaults/feature-defaults-section.tsx
@@ -3,7 +3,6 @@ import { Checkbox } from '@/components/ui/checkbox';
import {
FlaskConical,
TestTube,
- GitBranch,
AlertCircle,
Zap,
ClipboardList,
@@ -27,13 +26,11 @@ interface FeatureDefaultsSectionProps {
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
skipVerificationInAutoMode: boolean;
- useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
- onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
}
@@ -42,13 +39,11 @@ export function FeatureDefaultsSection({
defaultSkipTests,
enableDependencyBlocking,
skipVerificationInAutoMode,
- useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
- onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
}: FeatureDefaultsSectionProps) {
@@ -256,33 +251,6 @@ export function FeatureDefaultsSection({
-
- {/* Separator */}
-
-
- {/* Worktree Isolation Setting */}
-
-
onUseWorktreesChange(checked === true)}
- className="mt-1"
- data-testid="use-worktrees-checkbox"
- />
-
-
-
- Creates isolated git branches for each feature. When disabled, agents work directly in
- the main project directory.
-
-
-
);
diff --git a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts
index 1dc0208f..29d29ea2 100644
--- a/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts
+++ b/apps/ui/src/components/views/settings-view/hooks/use-settings-view.ts
@@ -16,6 +16,7 @@ export type SettingsViewId =
| 'keyboard'
| 'audio'
| 'defaults'
+ | 'worktrees'
| 'account'
| 'security'
| 'danger';
diff --git a/apps/ui/src/components/views/settings-view/worktrees/index.ts b/apps/ui/src/components/views/settings-view/worktrees/index.ts
new file mode 100644
index 00000000..a240bd72
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/worktrees/index.ts
@@ -0,0 +1 @@
+export { WorktreesSection } from './worktrees-section';
diff --git a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx
new file mode 100644
index 00000000..2d232a65
--- /dev/null
+++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx
@@ -0,0 +1,430 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Label } from '@/components/ui/label';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Button } from '@/components/ui/button';
+import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
+import {
+ GitBranch,
+ Terminal,
+ FileCode,
+ Save,
+ RotateCcw,
+ Trash2,
+ Loader2,
+ PanelBottomClose,
+} from 'lucide-react';
+import { cn } from '@/lib/utils';
+import { apiGet, apiPut, apiDelete } from '@/lib/api-fetch';
+import { toast } from 'sonner';
+import { useAppStore } from '@/store/app-store';
+import { getHttpApiClient } from '@/lib/http-api-client';
+
+interface WorktreesSectionProps {
+ useWorktrees: boolean;
+ onUseWorktreesChange: (value: boolean) => void;
+}
+
+interface InitScriptResponse {
+ success: boolean;
+ exists: boolean;
+ content: string;
+ path: string;
+ error?: string;
+}
+
+export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: WorktreesSectionProps) {
+ const currentProject = useAppStore((s) => s.currentProject);
+ const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
+ const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
+ const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
+ const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
+ const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
+ const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
+ const [scriptContent, setScriptContent] = useState('');
+ const [originalContent, setOriginalContent] = useState('');
+ const [scriptExists, setScriptExists] = useState(false);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSaving, setIsSaving] = useState(false);
+ const [isDeleting, setIsDeleting] = useState(false);
+
+ // Get the current show indicator setting
+ const showIndicator = currentProject?.path
+ ? getShowInitScriptIndicator(currentProject.path)
+ : true;
+
+ // Get the default delete branch setting
+ const defaultDeleteBranch = currentProject?.path
+ ? getDefaultDeleteBranch(currentProject.path)
+ : false;
+
+ // Get the auto-dismiss setting
+ const autoDismiss = currentProject?.path
+ ? getAutoDismissInitScriptIndicator(currentProject.path)
+ : true;
+
+ // Check if there are unsaved changes
+ const hasChanges = scriptContent !== originalContent;
+
+ // Load init script content when project changes
+ useEffect(() => {
+ if (!currentProject?.path) {
+ setScriptContent('');
+ setOriginalContent('');
+ setScriptExists(false);
+ setIsLoading(false);
+ return;
+ }
+
+ const loadInitScript = async () => {
+ setIsLoading(true);
+ try {
+ const response = await apiGet(
+ `/api/worktree/init-script?projectPath=${encodeURIComponent(currentProject.path)}`
+ );
+ if (response.success) {
+ const content = response.content || '';
+ setScriptContent(content);
+ setOriginalContent(content);
+ setScriptExists(response.exists);
+ }
+ } catch (error) {
+ console.error('Failed to load init script:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ loadInitScript();
+ }, [currentProject?.path]);
+
+ // Save script
+ const handleSave = useCallback(async () => {
+ if (!currentProject?.path) return;
+
+ setIsSaving(true);
+ try {
+ const response = await apiPut<{ success: boolean; error?: string }>(
+ '/api/worktree/init-script',
+ {
+ projectPath: currentProject.path,
+ content: scriptContent,
+ }
+ );
+ if (response.success) {
+ setOriginalContent(scriptContent);
+ setScriptExists(true);
+ toast.success('Init script saved');
+ } else {
+ toast.error('Failed to save init script', {
+ description: response.error,
+ });
+ }
+ } catch (error) {
+ console.error('Failed to save init script:', error);
+ toast.error('Failed to save init script');
+ } finally {
+ setIsSaving(false);
+ }
+ }, [currentProject?.path, scriptContent]);
+
+ // Reset to original content
+ const handleReset = useCallback(() => {
+ setScriptContent(originalContent);
+ }, [originalContent]);
+
+ // Delete script
+ const handleDelete = useCallback(async () => {
+ if (!currentProject?.path) return;
+
+ setIsDeleting(true);
+ try {
+ const response = await apiDelete<{ success: boolean; error?: string }>(
+ '/api/worktree/init-script',
+ {
+ body: { projectPath: currentProject.path },
+ }
+ );
+ if (response.success) {
+ setScriptContent('');
+ setOriginalContent('');
+ setScriptExists(false);
+ toast.success('Init script deleted');
+ } else {
+ toast.error('Failed to delete init script', {
+ description: response.error,
+ });
+ }
+ } catch (error) {
+ console.error('Failed to delete init script:', error);
+ toast.error('Failed to delete init script');
+ } finally {
+ setIsDeleting(false);
+ }
+ }, [currentProject?.path]);
+
+ // Handle content change (no auto-save)
+ const handleContentChange = useCallback((value: string) => {
+ setScriptContent(value);
+ }, []);
+
+ return (
+
+
+
+
+ Configure git worktree isolation and initialization scripts.
+
+
+
+ {/* Enable Worktrees Toggle */}
+
+
onUseWorktreesChange(checked === true)}
+ className="mt-1"
+ data-testid="use-worktrees-checkbox"
+ />
+
+
+
+ Creates isolated git branches for each feature. When disabled, agents work directly in
+ the main project directory.
+
+
+
+
+ {/* Show Init Script Indicator Toggle */}
+ {currentProject && (
+
+
{
+ if (currentProject?.path) {
+ const value = checked === true;
+ setShowInitScriptIndicator(currentProject.path, value);
+ // Persist to server
+ try {
+ const httpClient = getHttpApiClient();
+ await httpClient.settings.updateProject(currentProject.path, {
+ showInitScriptIndicator: value,
+ });
+ } catch (error) {
+ console.error('Failed to persist showInitScriptIndicator:', error);
+ }
+ }
+ }}
+ className="mt-1"
+ />
+
+
+
+ Display a floating panel in the bottom-right corner showing init script execution
+ status and output when a worktree is created.
+
+
+
+ )}
+
+ {/* Auto-dismiss Init Script Indicator Toggle */}
+ {currentProject && showIndicator && (
+
+
{
+ if (currentProject?.path) {
+ const value = checked === true;
+ setAutoDismissInitScriptIndicator(currentProject.path, value);
+ // Persist to server
+ try {
+ const httpClient = getHttpApiClient();
+ await httpClient.settings.updateProject(currentProject.path, {
+ autoDismissInitScriptIndicator: value,
+ });
+ } catch (error) {
+ console.error('Failed to persist autoDismissInitScriptIndicator:', error);
+ }
+ }
+ }}
+ className="mt-1"
+ />
+
+
+
+ Automatically hide the indicator 5 seconds after the script completes.
+
+
+
+ )}
+
+ {/* Default Delete Branch Toggle */}
+ {currentProject && (
+
+
{
+ if (currentProject?.path) {
+ const value = checked === true;
+ setDefaultDeleteBranch(currentProject.path, value);
+ // Persist to server
+ try {
+ const httpClient = getHttpApiClient();
+ await httpClient.settings.updateProject(currentProject.path, {
+ defaultDeleteBranch: value,
+ });
+ } catch (error) {
+ console.error('Failed to persist defaultDeleteBranch:', error);
+ }
+ }
+ }}
+ className="mt-1"
+ />
+
+
+
+ When deleting a worktree, automatically check the "Also delete the branch" option.
+
+
+
+ )}
+
+ {/* Separator */}
+
+
+ {/* Init Script Section */}
+
+
+
+
+
+
+
+
+ Shell commands to run after a worktree is created. Runs once per worktree. Uses Git Bash
+ on Windows for cross-platform compatibility.
+
+
+ {currentProject ? (
+ <>
+ {/* File path indicator */}
+
+
+ .automaker/worktree-init.sh
+ {hasChanges && (
+ (unsaved changes)
+ )}
+
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+
+
+ {/* Action buttons */}
+
+
+
+
+
+ >
+ )}
+ >
+ ) : (
+
+ Select a project to configure the init script.
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/ui/src/hooks/use-init-script-events.ts b/apps/ui/src/hooks/use-init-script-events.ts
new file mode 100644
index 00000000..aa51409f
--- /dev/null
+++ b/apps/ui/src/hooks/use-init-script-events.ts
@@ -0,0 +1,79 @@
+import { useEffect } from 'react';
+import { useAppStore } from '@/store/app-store';
+import { getHttpApiClient } from '@/lib/http-api-client';
+import { pathsEqual } from '@/lib/utils';
+
+interface InitScriptStartedPayload {
+ projectPath: string;
+ worktreePath: string;
+ branch: string;
+}
+
+interface InitScriptOutputPayload {
+ projectPath: string;
+ branch: string;
+ type: 'stdout' | 'stderr';
+ content: string;
+}
+
+interface InitScriptCompletedPayload {
+ projectPath: string;
+ worktreePath: string;
+ branch: string;
+ success: boolean;
+ exitCode?: number;
+ error?: string;
+}
+
+/**
+ * Hook to subscribe to init script WebSocket events and update the store.
+ * Should be used in a component that's always mounted (e.g., board-view).
+ */
+export function useInitScriptEvents(projectPath: string | null) {
+ const setInitScriptState = useAppStore((s) => s.setInitScriptState);
+ const appendInitScriptOutput = useAppStore((s) => s.appendInitScriptOutput);
+
+ useEffect(() => {
+ if (!projectPath) return;
+
+ const api = getHttpApiClient();
+
+ const unsubscribe = api.worktree.onInitScriptEvent((event) => {
+ const payload = event.payload as
+ | InitScriptStartedPayload
+ | InitScriptOutputPayload
+ | InitScriptCompletedPayload;
+
+ // Only handle events for the current project (use pathsEqual for cross-platform path comparison)
+ if (!pathsEqual(payload.projectPath, projectPath)) return;
+
+ switch (event.type) {
+ case 'worktree:init-started': {
+ const startPayload = payload as InitScriptStartedPayload;
+ setInitScriptState(projectPath, startPayload.branch, {
+ status: 'running',
+ branch: startPayload.branch,
+ output: [],
+ error: undefined,
+ });
+ break;
+ }
+ case 'worktree:init-output': {
+ const outputPayload = payload as InitScriptOutputPayload;
+ appendInitScriptOutput(projectPath, outputPayload.branch, outputPayload.content);
+ break;
+ }
+ case 'worktree:init-completed': {
+ const completePayload = payload as InitScriptCompletedPayload;
+ setInitScriptState(projectPath, completePayload.branch, {
+ status: completePayload.success ? 'success' : 'failed',
+ error: completePayload.error,
+ });
+ break;
+ }
+ }
+ });
+
+ return unsubscribe;
+ }, [projectPath, setInitScriptState, appendInitScriptOutput]);
+}
diff --git a/apps/ui/src/hooks/use-project-settings-loader.ts b/apps/ui/src/hooks/use-project-settings-loader.ts
index 62784f5f..4da50473 100644
--- a/apps/ui/src/hooks/use-project-settings-loader.ts
+++ b/apps/ui/src/hooks/use-project-settings-loader.ts
@@ -18,6 +18,11 @@ export function useProjectSettingsLoader() {
const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity);
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
+ const setShowInitScriptIndicator = useAppStore((state) => state.setShowInitScriptIndicator);
+ const setDefaultDeleteBranch = useAppStore((state) => state.setDefaultDeleteBranch);
+ const setAutoDismissInitScriptIndicator = useAppStore(
+ (state) => state.setAutoDismissInitScriptIndicator
+ );
const loadingRef = useRef(null);
const currentProjectRef = useRef(null);
@@ -78,6 +83,27 @@ export function useProjectSettingsLoader() {
if (result.settings.worktreePanelVisible !== undefined) {
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
}
+
+ // Apply showInitScriptIndicator if present
+ if (result.settings.showInitScriptIndicator !== undefined) {
+ setShowInitScriptIndicator(
+ requestedProjectPath,
+ result.settings.showInitScriptIndicator
+ );
+ }
+
+ // Apply defaultDeleteBranch if present
+ if (result.settings.defaultDeleteBranch !== undefined) {
+ setDefaultDeleteBranch(requestedProjectPath, result.settings.defaultDeleteBranch);
+ }
+
+ // Apply autoDismissInitScriptIndicator if present
+ if (result.settings.autoDismissInitScriptIndicator !== undefined) {
+ setAutoDismissInitScriptIndicator(
+ requestedProjectPath,
+ result.settings.autoDismissInitScriptIndicator
+ );
+ }
}
} catch (error) {
console.error('Failed to load project settings:', error);
diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts
index c93eba79..29c8aa2e 100644
--- a/apps/ui/src/lib/electron.ts
+++ b/apps/ui/src/lib/electron.ts
@@ -651,7 +651,8 @@ export interface ElectronAPI {
removedDependencies: string[];
addedDependencies: string[];
}>;
- }
+ },
+ branchName?: string
) => Promise<{ success: boolean; appliedChanges?: string[]; error?: string }>;
onEvent: (callback: (data: unknown) => void) => () => void;
};
@@ -1769,6 +1770,47 @@ function createMockWorktreeAPI(): WorktreeAPI {
},
};
},
+
+ getInitScript: async (projectPath: string) => {
+ console.log('[Mock] Getting init script:', { projectPath });
+ return {
+ success: true,
+ exists: false,
+ content: '',
+ path: `${projectPath}/.automaker/worktree-init.sh`,
+ };
+ },
+
+ setInitScript: async (projectPath: string, content: string) => {
+ console.log('[Mock] Setting init script:', { projectPath, content });
+ return {
+ success: true,
+ path: `${projectPath}/.automaker/worktree-init.sh`,
+ };
+ },
+
+ deleteInitScript: async (projectPath: string) => {
+ console.log('[Mock] Deleting init script:', { projectPath });
+ return {
+ success: true,
+ };
+ },
+
+ runInitScript: async (projectPath: string, worktreePath: string, branch: string) => {
+ console.log('[Mock] Running init script:', { projectPath, worktreePath, branch });
+ return {
+ success: true,
+ message: 'Init script started (mock)',
+ };
+ },
+
+ onInitScriptEvent: (callback) => {
+ console.log('[Mock] Subscribing to init script events');
+ // Return unsubscribe function
+ return () => {
+ console.log('[Mock] Unsubscribing from init script events');
+ };
+ },
};
}
diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts
index 62b0c734..d3b71a74 100644
--- a/apps/ui/src/lib/http-api-client.ts
+++ b/apps/ui/src/lib/http-api-client.ts
@@ -507,7 +507,10 @@ type EventType =
| 'issue-validation:event'
| 'backlog-plan:event'
| 'ideation:stream'
- | 'ideation:analysis';
+ | 'ideation:analysis'
+ | 'worktree:init-started'
+ | 'worktree:init-output'
+ | 'worktree:init-completed';
type EventCallback = (payload: unknown) => void;
@@ -846,13 +849,14 @@ export class HttpApiClient implements ElectronAPI {
return response.json();
}
- private async httpDelete(endpoint: string): Promise {
+ private async httpDelete(endpoint: string, body?: unknown): Promise {
// Ensure API key is initialized before making request
await waitForApiKeyInit();
const response = await fetch(`${this.serverUrl}${endpoint}`, {
method: 'DELETE',
headers: this.getHeaders(),
credentials: 'include', // Include cookies for session auth
+ body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401 || response.status === 403) {
@@ -1647,6 +1651,37 @@ export class HttpApiClient implements ElectronAPI {
listDevServers: () => this.post('/api/worktree/list-dev-servers', {}),
getPRInfo: (worktreePath: string, branchName: string) =>
this.post('/api/worktree/pr-info', { worktreePath, branchName }),
+ // Init script methods
+ getInitScript: (projectPath: string) =>
+ this.get(`/api/worktree/init-script?projectPath=${encodeURIComponent(projectPath)}`),
+ setInitScript: (projectPath: string, content: string) =>
+ this.put('/api/worktree/init-script', { projectPath, content }),
+ deleteInitScript: (projectPath: string) =>
+ this.httpDelete('/api/worktree/init-script', { projectPath }),
+ runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
+ this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
+ onInitScriptEvent: (
+ callback: (event: {
+ type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
+ payload: unknown;
+ }) => void
+ ) => {
+ // Note: subscribeToEvent callback receives (payload) not (_, payload)
+ const unsub1 = this.subscribeToEvent('worktree:init-started', (payload) =>
+ callback({ type: 'worktree:init-started', payload })
+ );
+ const unsub2 = this.subscribeToEvent('worktree:init-output', (payload) =>
+ callback({ type: 'worktree:init-output', payload })
+ );
+ const unsub3 = this.subscribeToEvent('worktree:init-completed', (payload) =>
+ callback({ type: 'worktree:init-completed', payload })
+ );
+ return () => {
+ unsub1();
+ unsub2();
+ unsub3();
+ };
+ },
};
// Git API
@@ -2167,9 +2202,10 @@ export class HttpApiClient implements ElectronAPI {
removedDependencies: string[];
addedDependencies: string[];
}>;
- }
+ },
+ branchName?: string
): Promise<{ success: boolean; appliedChanges?: string[]; error?: string }> =>
- this.post('/api/backlog-plan/apply', { projectPath, plan }),
+ this.post('/api/backlog-plan/apply', { projectPath, plan, branchName }),
onEvent: (callback: (data: unknown) => void): (() => void) => {
return this.subscribeToEvent('backlog-plan:event', callback as EventCallback);
diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts
index 4b9e319c..e9593f3c 100644
--- a/apps/ui/src/store/app-store.ts
+++ b/apps/ui/src/store/app-store.ts
@@ -80,6 +80,9 @@ export type ThemeMode =
// LocalStorage key for theme persistence (fallback when server settings aren't available)
export const THEME_STORAGE_KEY = 'automaker:theme';
+// Maximum number of output lines to keep in init script state (prevents unbounded memory growth)
+export const MAX_INIT_OUTPUT_LINES = 500;
+
/**
* Get the theme from localStorage as a fallback
* Used before server settings are loaded (e.g., on login/setup pages)
@@ -469,6 +472,14 @@ export interface PersistedTerminalSettings {
maxSessions: number;
}
+/** State for worktree init script execution */
+export interface InitScriptState {
+ status: 'idle' | 'running' | 'success' | 'failed';
+ branch: string;
+ output: string[];
+ error?: string;
+}
+
export interface AppState {
// Project state
projects: Project[];
@@ -522,6 +533,8 @@ export interface AppState {
defaultSkipTests: boolean; // Default value for skip tests when creating new features
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
+ planUseSelectedWorktreeBranch: boolean; // When true, Plan dialog creates features on the currently selected worktree branch
+ addFeatureUseSelectedWorktreeBranch: boolean; // When true, Add Feature dialog defaults to custom mode with selected worktree branch
// Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: true)
@@ -670,6 +683,18 @@ export interface AppState {
// Whether the worktree panel row is visible (default: true)
worktreePanelVisibleByProject: Record;
+ // Init Script Indicator Visibility (per-project, keyed by project path)
+ // Whether to show the floating init script indicator panel (default: true)
+ showInitScriptIndicatorByProject: Record;
+
+ // Default Delete Branch With Worktree (per-project, keyed by project path)
+ // Whether to default the "delete branch" checkbox when deleting a worktree (default: false)
+ defaultDeleteBranchByProject: Record;
+
+ // Auto-dismiss Init Script Indicator (per-project, keyed by project path)
+ // Whether to auto-dismiss the indicator after completion (default: true)
+ autoDismissInitScriptIndicatorByProject: Record;
+
// UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean;
@@ -677,6 +702,9 @@ export interface AppState {
lastProjectDir: string;
/** Recently accessed folders for quick access */
recentFolders: string[];
+
+ // Init Script State (keyed by "projectPath::branch" to support concurrent scripts)
+ initScriptState: Record;
}
// Claude Usage interface matching the server response
@@ -890,6 +918,8 @@ export interface AppActions {
setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise;
+ setPlanUseSelectedWorktreeBranch: (enabled: boolean) => Promise;
+ setAddFeatureUseSelectedWorktreeBranch: (enabled: boolean) => Promise;
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
@@ -1083,6 +1113,18 @@ export interface AppActions {
setWorktreePanelVisible: (projectPath: string, visible: boolean) => void;
getWorktreePanelVisible: (projectPath: string) => boolean;
+ // Init Script Indicator Visibility actions (per-project)
+ setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
+ getShowInitScriptIndicator: (projectPath: string) => boolean;
+
+ // Default Delete Branch actions (per-project)
+ setDefaultDeleteBranch: (projectPath: string, deleteBranch: boolean) => void;
+ getDefaultDeleteBranch: (projectPath: string) => boolean;
+
+ // Auto-dismiss Init Script Indicator actions (per-project)
+ setAutoDismissInitScriptIndicator: (projectPath: string, autoDismiss: boolean) => void;
+ getAutoDismissInitScriptIndicator: (projectPath: string) => boolean;
+
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void;
@@ -1111,6 +1153,19 @@ export interface AppActions {
}>
) => void;
+ // Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
+ setInitScriptState: (
+ projectPath: string,
+ branch: string,
+ state: Partial
+ ) => void;
+ appendInitScriptOutput: (projectPath: string, branch: string, content: string) => void;
+ clearInitScriptState: (projectPath: string, branch: string) => void;
+ getInitScriptState: (projectPath: string, branch: string) => InitScriptState | null;
+ getInitScriptStatesForProject: (
+ projectPath: string
+ ) => Array<{ key: string; state: InitScriptState }>;
+
// Reset
reset: () => void;
}
@@ -1143,6 +1198,8 @@ const initialState: AppState = {
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
+ planUseSelectedWorktreeBranch: true, // Default to enabled (Plan creates features on selected worktree branch)
+ addFeatureUseSelectedWorktreeBranch: false, // Default to disabled (Add Feature uses normal defaults)
useWorktrees: true, // Default to enabled (git worktree isolation)
currentWorktreeByProject: {},
worktreesByProject: {},
@@ -1208,10 +1265,14 @@ const initialState: AppState = {
codexModelsLastFetched: null,
pipelineConfigByProject: {},
worktreePanelVisibleByProject: {},
+ showInitScriptIndicatorByProject: {},
+ defaultDeleteBranchByProject: {},
+ autoDismissInitScriptIndicatorByProject: {},
// UI State (previously in localStorage, now synced via API)
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
+ initScriptState: {},
};
export const useAppStore = create()((set, get) => ({
@@ -1764,6 +1825,30 @@ export const useAppStore = create()((set, get) => ({
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
+ setPlanUseSelectedWorktreeBranch: async (enabled) => {
+ const previous = get().planUseSelectedWorktreeBranch;
+ set({ planUseSelectedWorktreeBranch: enabled });
+ // Sync to server settings file
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ const ok = await syncSettingsToServer();
+ if (!ok) {
+ logger.error('Failed to sync planUseSelectedWorktreeBranch setting to server - reverting');
+ set({ planUseSelectedWorktreeBranch: previous });
+ }
+ },
+ setAddFeatureUseSelectedWorktreeBranch: async (enabled) => {
+ const previous = get().addFeatureUseSelectedWorktreeBranch;
+ set({ addFeatureUseSelectedWorktreeBranch: enabled });
+ // Sync to server settings file
+ const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
+ const ok = await syncSettingsToServer();
+ if (!ok) {
+ logger.error(
+ 'Failed to sync addFeatureUseSelectedWorktreeBranch setting to server - reverting'
+ );
+ set({ addFeatureUseSelectedWorktreeBranch: previous });
+ }
+ },
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
@@ -3136,6 +3221,51 @@ export const useAppStore = create()((set, get) => ({
return get().worktreePanelVisibleByProject[projectPath] ?? true;
},
+ // Init Script Indicator Visibility actions (per-project)
+ setShowInitScriptIndicator: (projectPath, visible) => {
+ set({
+ showInitScriptIndicatorByProject: {
+ ...get().showInitScriptIndicatorByProject,
+ [projectPath]: visible,
+ },
+ });
+ },
+
+ getShowInitScriptIndicator: (projectPath) => {
+ // Default to true (visible) if not set
+ return get().showInitScriptIndicatorByProject[projectPath] ?? true;
+ },
+
+ // Default Delete Branch actions (per-project)
+ setDefaultDeleteBranch: (projectPath, deleteBranch) => {
+ set({
+ defaultDeleteBranchByProject: {
+ ...get().defaultDeleteBranchByProject,
+ [projectPath]: deleteBranch,
+ },
+ });
+ },
+
+ getDefaultDeleteBranch: (projectPath) => {
+ // Default to false (don't delete branch) if not set
+ return get().defaultDeleteBranchByProject[projectPath] ?? false;
+ },
+
+ // Auto-dismiss Init Script Indicator actions (per-project)
+ setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) => {
+ set({
+ autoDismissInitScriptIndicatorByProject: {
+ ...get().autoDismissInitScriptIndicatorByProject,
+ [projectPath]: autoDismiss,
+ },
+ });
+ },
+
+ getAutoDismissInitScriptIndicator: (projectPath) => {
+ // Default to true (auto-dismiss enabled) if not set
+ return get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true;
+ },
+
// UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
@@ -3149,6 +3279,62 @@ export const useAppStore = create()((set, get) => ({
set({ recentFolders: updated });
},
+ // Init Script State actions (keyed by "projectPath::branch")
+ setInitScriptState: (projectPath, branch, state) => {
+ const key = `${projectPath}::${branch}`;
+ const current = get().initScriptState[key] || {
+ status: 'idle',
+ branch,
+ output: [],
+ };
+ set({
+ initScriptState: {
+ ...get().initScriptState,
+ [key]: { ...current, ...state },
+ },
+ });
+ },
+
+ appendInitScriptOutput: (projectPath, branch, content) => {
+ const key = `${projectPath}::${branch}`;
+ // Initialize state if absent to avoid dropping output due to event-order races
+ const current = get().initScriptState[key] || {
+ status: 'idle' as const,
+ branch,
+ output: [],
+ };
+ // Append new content and enforce fixed-size buffer to prevent memory bloat
+ const newOutput = [...current.output, content].slice(-MAX_INIT_OUTPUT_LINES);
+ set({
+ initScriptState: {
+ ...get().initScriptState,
+ [key]: {
+ ...current,
+ output: newOutput,
+ },
+ },
+ });
+ },
+
+ clearInitScriptState: (projectPath, branch) => {
+ const key = `${projectPath}::${branch}`;
+ const { [key]: _, ...rest } = get().initScriptState;
+ set({ initScriptState: rest });
+ },
+
+ getInitScriptState: (projectPath, branch) => {
+ const key = `${projectPath}::${branch}`;
+ return get().initScriptState[key] || null;
+ },
+
+ getInitScriptStatesForProject: (projectPath) => {
+ const prefix = `${projectPath}::`;
+ const states = get().initScriptState;
+ return Object.entries(states)
+ .filter(([key]) => key.startsWith(prefix))
+ .map(([key, state]) => ({ key, state }));
+ },
+
// Reset
reset: () => set(initialState),
}));
diff --git a/apps/ui/src/types/electron.d.ts b/apps/ui/src/types/electron.d.ts
index 745f6956..4c9cce55 100644
--- a/apps/ui/src/types/electron.d.ts
+++ b/apps/ui/src/types/electron.d.ts
@@ -1015,6 +1015,50 @@ export interface WorktreeAPI {
};
error?: string;
}>;
+
+ // Get init script content for a project
+ getInitScript: (projectPath: string) => Promise<{
+ success: boolean;
+ exists: boolean;
+ content: string;
+ path: string;
+ error?: string;
+ }>;
+
+ // Set init script content for a project
+ setInitScript: (
+ projectPath: string,
+ content: string
+ ) => Promise<{
+ success: boolean;
+ path?: string;
+ error?: string;
+ }>;
+
+ // Delete init script for a project
+ deleteInitScript: (projectPath: string) => Promise<{
+ success: boolean;
+ error?: string;
+ }>;
+
+ // Run (or re-run) init script for a worktree
+ runInitScript: (
+ projectPath: string,
+ worktreePath: string,
+ branch: string
+ ) => Promise<{
+ success: boolean;
+ message?: string;
+ error?: string;
+ }>;
+
+ // Subscribe to init script events
+ onInitScriptEvent: (
+ callback: (event: {
+ type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';
+ payload: unknown;
+ }) => void
+ ) => () => void;
}
export interface GitAPI {
diff --git a/docs/worktree-init-script-example.sh b/docs/worktree-init-script-example.sh
new file mode 100644
index 00000000..2f942544
--- /dev/null
+++ b/docs/worktree-init-script-example.sh
@@ -0,0 +1,30 @@
+#!/bin/bash
+# Example worktree init script for Automaker
+# Copy this content to Settings > Worktrees > Init Script
+# Or save directly as .automaker/worktree-init.sh in your project
+
+echo "=========================================="
+echo " Worktree Init Script Starting..."
+echo "=========================================="
+echo ""
+echo "Current directory: $(pwd)"
+echo "Branch: $(git branch --show-current 2>/dev/null || echo 'unknown')"
+echo ""
+
+# Install dependencies
+echo "[1/1] Installing npm dependencies..."
+if [ -f "package.json" ]; then
+ if npm install; then
+ echo "Dependencies installed successfully!"
+ else
+ echo "ERROR: npm install failed with exit code $?"
+ exit 1
+ fi
+else
+ echo "No package.json found, skipping npm install"
+fi
+echo ""
+
+echo "=========================================="
+echo " Worktree initialization complete!"
+echo "=========================================="
diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts
index 5fd985c4..cd37da49 100644
--- a/libs/platform/src/index.ts
+++ b/libs/platform/src/index.ts
@@ -97,6 +97,7 @@ export {
getCodexCliPaths,
getCodexConfigDir,
getCodexAuthPath,
+ getGitBashPaths,
getOpenCodeCliPaths,
getOpenCodeConfigDir,
getOpenCodeAuthPath,
@@ -130,6 +131,7 @@ export {
findCodexCliPath,
getCodexAuthIndicators,
type CodexAuthIndicators,
+ findGitBashPath,
findOpenCodeCliPath,
getOpenCodeAuthIndicators,
type OpenCodeAuthIndicators,
diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts
index c1faee26..31382a33 100644
--- a/libs/platform/src/system-paths.ts
+++ b/libs/platform/src/system-paths.ts
@@ -232,6 +232,87 @@ export function getClaudeProjectsDir(): string {
return path.join(getClaudeConfigDir(), 'projects');
}
+/**
+ * Enumerate directories matching a prefix pattern and return full paths
+ * Used to resolve dynamic directory names like version numbers
+ */
+function enumerateMatchingPaths(
+ parentDir: string,
+ prefix: string,
+ ...subPathParts: string[]
+): string[] {
+ try {
+ if (!fsSync.existsSync(parentDir)) {
+ return [];
+ }
+ const entries = fsSync.readdirSync(parentDir);
+ const matching = entries.filter((entry) => entry.startsWith(prefix));
+ return matching.map((entry) => path.join(parentDir, entry, ...subPathParts));
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Get common Git Bash installation paths on Windows
+ * Git Bash is needed for running shell scripts cross-platform
+ */
+export function getGitBashPaths(): string[] {
+ if (process.platform !== 'win32') {
+ return [];
+ }
+
+ const homeDir = os.homedir();
+ const localAppData = process.env.LOCALAPPDATA || '';
+
+ // Dynamic paths that require directory enumeration
+ // winget installs to: LocalAppData\Microsoft\WinGet\Packages\Git.Git_\bin\bash.exe
+ const wingetGitPaths = localAppData
+ ? enumerateMatchingPaths(
+ path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'),
+ 'Git.Git_',
+ 'bin',
+ 'bash.exe'
+ )
+ : [];
+
+ // GitHub Desktop bundles Git at: LocalAppData\GitHubDesktop\app-\resources\app\git\cmd\bash.exe
+ const githubDesktopPaths = localAppData
+ ? enumerateMatchingPaths(
+ path.join(localAppData, 'GitHubDesktop'),
+ 'app-',
+ 'resources',
+ 'app',
+ 'git',
+ 'cmd',
+ 'bash.exe'
+ )
+ : [];
+
+ return [
+ // Standard Git for Windows installations
+ 'C:\\Program Files\\Git\\bin\\bash.exe',
+ 'C:\\Program Files (x86)\\Git\\bin\\bash.exe',
+ // User-local installations
+ path.join(localAppData, 'Programs', 'Git', 'bin', 'bash.exe'),
+ // Scoop package manager
+ path.join(homeDir, 'scoop', 'apps', 'git', 'current', 'bin', 'bash.exe'),
+ // Chocolatey
+ path.join(
+ process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
+ 'lib',
+ 'git',
+ 'tools',
+ 'bin',
+ 'bash.exe'
+ ),
+ // winget installations (dynamically resolved)
+ ...wingetGitPaths,
+ // GitHub Desktop bundled Git (dynamically resolved)
+ ...githubDesktopPaths,
+ ].filter(Boolean);
+}
+
/**
* Get common shell paths for shell detection
* Includes both full paths and short names to match $SHELL or PATH entries
@@ -550,6 +631,8 @@ function getAllAllowedSystemPaths(): string[] {
getOpenCodeAuthPath(),
// Shell paths
...getShellPaths(),
+ // Git Bash paths (for Windows cross-platform shell script execution)
+ ...getGitBashPaths(),
// Node.js system paths
...getNodeSystemPaths(),
getScoopNodePath(),
@@ -883,6 +966,13 @@ export async function findCodexCliPath(): Promise {
return findFirstExistingPath(getCodexCliPaths());
}
+/**
+ * Find Git Bash on Windows and return its path
+ */
+export async function findGitBashPath(): Promise {
+ return findFirstExistingPath(getGitBashPaths());
+}
+
/**
* Get Claude authentication status by checking various indicators
*/
diff --git a/libs/types/src/event.ts b/libs/types/src/event.ts
index 6692f0f0..092c80bd 100644
--- a/libs/types/src/event.ts
+++ b/libs/types/src/event.ts
@@ -39,6 +39,9 @@ export type EventType =
| 'ideation:idea-created'
| 'ideation:idea-updated'
| 'ideation:idea-deleted'
- | 'ideation:idea-converted';
+ | 'ideation:idea-converted'
+ | 'worktree:init-started'
+ | 'worktree:init-output'
+ | 'worktree:init-completed';
export type EventCallback = (type: EventType, payload: unknown) => void;
diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts
index 5b51c793..8ec4ef6c 100644
--- a/libs/types/src/settings.ts
+++ b/libs/types/src/settings.ts
@@ -599,6 +599,14 @@ export interface ProjectSettings {
// UI Visibility
/** Whether the worktree panel row is visible (default: true) */
worktreePanelVisible?: boolean;
+ /** Whether to show the init script indicator panel (default: true) */
+ showInitScriptIndicator?: boolean;
+
+ // Worktree Behavior
+ /** Default value for "delete branch" checkbox when deleting a worktree (default: false) */
+ defaultDeleteBranchWithWorktree?: boolean;
+ /** Auto-dismiss init script indicator after completion (default: true) */
+ autoDismissInitScriptIndicator?: boolean;
// Session Tracking
/** Last chat session selected in this project */
diff --git a/package-lock.json b/package-lock.json
index f358ed5d..9ec0585e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -87,6 +87,8 @@
"@automaker/dependency-resolver": "1.0.0",
"@automaker/types": "1.0.0",
"@codemirror/lang-xml": "6.1.0",
+ "@codemirror/language": "^6.12.1",
+ "@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "6.1.3",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/sortable": "10.0.0",
@@ -1199,19 +1201,28 @@
}
},
"node_modules/@codemirror/language": {
- "version": "6.11.3",
- "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
- "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
+ "version": "6.12.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz",
+ "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
- "@lezer/common": "^1.1.0",
+ "@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
+ "node_modules/@codemirror/legacy-modes": {
+ "version": "6.5.2",
+ "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz",
+ "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0"
+ }
+ },
"node_modules/@codemirror/lint": {
"version": "6.9.2",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz",
@@ -3604,9 +3615,9 @@
}
},
"node_modules/@lezer/common": {
- "version": "1.4.0",
- "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.4.0.tgz",
- "integrity": "sha512-DVeMRoGrgn/k45oQNu189BoW4SZwgZFzJ1+1TV5j2NJ/KFC83oa/enRqZSGshyeMk5cPWMhsKs9nx+8o0unwGg==",
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz",
+ "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {