completly remove sandbox related code as the downstream libraries do not work with it on various os

This commit is contained in:
webdevcody
2026-01-07 08:54:14 -05:00
parent 4d4025ca06
commit 1316ead8c8
34 changed files with 589 additions and 1230 deletions

View File

@@ -16,7 +16,6 @@
*/
import type { Options } from '@anthropic-ai/claude-agent-sdk';
import os from 'os';
import path from 'path';
import { resolveModelString } from '@automaker/model-resolver';
import { createLogger } from '@automaker/utils';
@@ -57,139 +56,6 @@ export function validateWorkingDirectory(cwd: string): void {
}
}
/**
* Known cloud storage path patterns where sandbox mode is incompatible.
*
* The Claude CLI sandbox feature uses filesystem isolation that conflicts with
* cloud storage providers' virtual filesystem implementations. This causes the
* Claude process to exit with code 1 when sandbox is enabled for these paths.
*
* Affected providers (macOS paths):
* - Dropbox: ~/Library/CloudStorage/Dropbox-*
* - Google Drive: ~/Library/CloudStorage/GoogleDrive-*
* - OneDrive: ~/Library/CloudStorage/OneDrive-*
* - iCloud Drive: ~/Library/Mobile Documents/
* - Box: ~/Library/CloudStorage/Box-*
*
* Note: This is a known limitation when using cloud storage paths.
*/
/**
* macOS-specific cloud storage patterns that appear under ~/Library/
* These are specific enough to use with includes() safely.
*/
const MACOS_CLOUD_STORAGE_PATTERNS = [
'/Library/CloudStorage/', // Dropbox, Google Drive, OneDrive, Box on macOS
'/Library/Mobile Documents/', // iCloud Drive on macOS
] as const;
/**
* Generic cloud storage folder names that need to be anchored to the home directory
* to avoid false positives (e.g., /home/user/my-project-about-dropbox/).
*/
const HOME_ANCHORED_CLOUD_FOLDERS = [
'Google Drive', // Google Drive on some systems
'Dropbox', // Dropbox on Linux/alternative installs
'OneDrive', // OneDrive on Linux/alternative installs
] as const;
/**
* Check if a path is within a cloud storage location.
*
* Cloud storage providers use virtual filesystem implementations that are
* incompatible with the Claude CLI sandbox feature, causing process crashes.
*
* Uses two detection strategies:
* 1. macOS-specific patterns (under ~/Library/) - checked via includes()
* 2. Generic folder names - anchored to home directory to avoid false positives
*
* @param cwd - The working directory path to check
* @returns true if the path is in a cloud storage location
*/
export function isCloudStoragePath(cwd: string): boolean {
const resolvedPath = path.resolve(cwd);
// Normalize to forward slashes for consistent pattern matching across platforms
let normalizedPath = resolvedPath.split(path.sep).join('/');
// Remove Windows drive letter if present (e.g., "C:/Users" -> "/Users")
// This ensures Unix paths in tests work the same on Windows
normalizedPath = normalizedPath.replace(/^[A-Za-z]:/, '');
// Check macOS-specific patterns (these are specific enough to use includes)
if (MACOS_CLOUD_STORAGE_PATTERNS.some((pattern) => normalizedPath.includes(pattern))) {
return true;
}
// Check home-anchored patterns to avoid false positives
// e.g., /home/user/my-project-about-dropbox/ should NOT match
const home = os.homedir();
for (const folder of HOME_ANCHORED_CLOUD_FOLDERS) {
const cloudPath = path.join(home, folder);
let normalizedCloudPath = cloudPath.split(path.sep).join('/');
// Remove Windows drive letter if present
normalizedCloudPath = normalizedCloudPath.replace(/^[A-Za-z]:/, '');
// Check if resolved path starts with the cloud storage path followed by a separator
// This ensures we match ~/Dropbox/project but not ~/Dropbox-archive or ~/my-dropbox-tool
if (
normalizedPath === normalizedCloudPath ||
normalizedPath.startsWith(normalizedCloudPath + '/')
) {
return true;
}
}
return false;
}
/**
* Result of sandbox compatibility check
*/
export interface SandboxCheckResult {
/** Whether sandbox should be enabled */
enabled: boolean;
/** If disabled, the reason why */
disabledReason?: 'cloud_storage' | 'user_setting';
/** Human-readable message for logging/UI */
message?: string;
}
/**
* Determine if sandbox mode should be enabled for a given configuration.
*
* Sandbox mode is automatically disabled for cloud storage paths because the
* Claude CLI sandbox feature is incompatible with virtual filesystem
* implementations used by cloud storage providers (Dropbox, Google Drive, etc.).
*
* @param cwd - The working directory
* @param enableSandboxMode - User's sandbox mode setting
* @returns SandboxCheckResult with enabled status and reason if disabled
*/
export function checkSandboxCompatibility(
cwd: string,
enableSandboxMode?: boolean
): SandboxCheckResult {
// User has explicitly disabled sandbox mode
if (enableSandboxMode === false) {
return {
enabled: false,
disabledReason: 'user_setting',
};
}
// Check for cloud storage incompatibility (applies when enabled or undefined)
if (isCloudStoragePath(cwd)) {
return {
enabled: false,
disabledReason: 'cloud_storage',
message: `Sandbox mode auto-disabled: Project is in a cloud storage location (${cwd}). The Claude CLI sandbox feature is incompatible with cloud storage filesystems. To use sandbox mode, move your project to a local directory.`,
};
}
// Sandbox is compatible and enabled (true or undefined defaults to enabled)
return {
enabled: true,
};
}
/**
* Tool presets for different use cases
*/
@@ -272,55 +138,31 @@ export function getModelForUseCase(
/**
* Base options that apply to all SDK calls
* AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
*/
function getBaseOptions(): Partial<Options> {
return {
permissionMode: 'acceptEdits',
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
};
}
/**
* MCP permission options result
* MCP options result
*/
interface McpPermissionOptions {
/** Whether tools should be restricted to a preset */
shouldRestrictTools: boolean;
/** Options to spread when MCP bypass is enabled */
bypassOptions: Partial<Options>;
interface McpOptions {
/** Options to spread for MCP servers */
mcpServerOptions: Partial<Options>;
}
/**
* Build MCP-related options based on configuration.
* Centralizes the logic for determining permission modes and tool restrictions
* when MCP servers are configured.
*
* @param config - The SDK options config
* @returns Object with MCP permission settings to spread into final options
* @returns Object with MCP server settings to spread into final options
*/
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
// Default to true for autonomous workflow. Security is enforced when adding servers
// via the security warning dialog that explains the risks.
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
// Determine if we should bypass permissions based on settings
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
return {
shouldRestrictTools,
// Only include bypass options when MCP is configured and auto-approve is enabled
bypassOptions: shouldBypassPermissions
? {
permissionMode: 'bypassPermissions' as const,
// Required flag when using bypassPermissions mode
allowDangerouslySkipPermissions: true,
}
: {},
// Include MCP servers if configured
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
};
@@ -422,18 +264,9 @@ export interface CreateSdkOptionsConfig {
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean;
/** Enable sandbox mode for bash command isolation */
enableSandboxMode?: boolean;
/** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>;
/** Auto-approve MCP tool calls without permission prompts */
mcpAutoApproveTools?: boolean;
/** Allow unrestricted tools when MCP servers are enabled */
mcpUnrestrictedTools?: boolean;
/** Extended thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
}
@@ -554,7 +387,6 @@ export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Option
* - Full tool access for code modification
* - Standard turns for interactive sessions
* - Model priority: explicit model > session model > chat default
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
@@ -573,24 +405,12 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
return {
...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel),
maxTurns: MAX_TURNS.standard,
cwd: config.cwd,
// Only restrict tools if no MCP servers configured or unrestricted is disabled
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
...(sandboxCheck.enabled && {
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
allowedTools: [...TOOL_PRESETS.chat],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
@@ -605,7 +425,6 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
* - Full tool access for code modification and implementation
* - Extended turns for thorough feature implementation
* - Uses default model (can be overridden)
* - Sandbox mode controlled by enableSandboxMode setting (auto-disabled for cloud storage)
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
@@ -621,24 +440,12 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// Check sandbox compatibility (auto-disables for cloud storage paths)
const sandboxCheck = checkSandboxCompatibility(config.cwd, config.enableSandboxMode);
return {
...getBaseOptions(),
model: getModelForUseCase('auto', config.model),
maxTurns: MAX_TURNS.maximum,
cwd: config.cwd,
// Only restrict tools if no MCP servers configured or unrestricted is disabled
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
...(sandboxCheck.enabled && {
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
}),
allowedTools: [...TOOL_PRESETS.fullAccess],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
@@ -656,7 +463,6 @@ export function createCustomOptions(
config: CreateSdkOptionsConfig & {
maxTurns?: number;
allowedTools?: readonly string[];
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean };
}
): Options {
// Validate working directory before creating options
@@ -671,22 +477,17 @@ export function createCustomOptions(
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
// For custom options: use explicit allowedTools if provided, otherwise default to readOnly
const effectiveAllowedTools = config.allowedTools
? [...config.allowedTools]
: mcpOptions.shouldRestrictTools
? [...TOOL_PRESETS.readOnly]
: undefined;
: [...TOOL_PRESETS.readOnly];
return {
...getBaseOptions(),
model: getModelForUseCase('default', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
...(config.sandbox && { sandbox: config.sandbox }),
// Apply MCP bypass options if configured
...mcpOptions.bypassOptions,
allowedTools: effectiveAllowedTools,
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),

View File

@@ -55,34 +55,6 @@ export async function getAutoLoadClaudeMdSetting(
}
}
/**
* Get the enableSandboxMode setting from global settings.
* Returns false if settings service is not available.
*
* @param settingsService - Optional settings service instance
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
* @returns Promise resolving to the enableSandboxMode setting value
*/
export async function getEnableSandboxModeSetting(
settingsService?: SettingsService | null,
logPrefix = '[SettingsHelper]'
): Promise<boolean> {
if (!settingsService) {
logger.info(`${logPrefix} SettingsService not available, sandbox mode disabled`);
return false;
}
try {
const globalSettings = await settingsService.getGlobalSettings();
const result = globalSettings.enableSandboxMode ?? false;
logger.info(`${logPrefix} enableSandboxMode from global settings: ${result}`);
return result;
} catch (error) {
logger.error(`${logPrefix} Failed to load enableSandboxMode setting:`, error);
throw error;
}
}
/**
* Filters out CLAUDE.md from context files when autoLoadClaudeMd is enabled
* and rebuilds the formatted prompt without it.

View File

@@ -70,14 +70,6 @@ export class ClaudeProvider extends BaseProvider {
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
// Build Claude SDK options
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
// AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools
// Only restrict tools when no MCP servers are configured
const shouldRestrictTools = !hasMcpServers;
const sdkOptions: Options = {
model,
systemPrompt,
@@ -85,10 +77,9 @@ export class ClaudeProvider extends BaseProvider {
cwd,
// Pass only explicitly allowed environment variables to SDK
env: buildEnv(),
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
...(allowedTools && shouldRestrictTools && { allowedTools }),
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
// AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations
// Pass through allowedTools if provided by caller (decided by sdk-options.ts)
...(allowedTools && { allowedTools }),
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
abortController,
@@ -98,8 +89,6 @@ export class ClaudeProvider extends BaseProvider {
: {}),
// Forward settingSources for CLAUDE.md file loading
...(options.settingSources && { settingSources: options.settingSources }),
// Forward sandbox configuration
...(options.sandbox && { sandbox: options.sandbox }),
// Forward MCP servers configuration
...(options.mcpServers && { mcpServers: options.mcpServers }),
// Extended thinking configuration

View File

@@ -31,7 +31,9 @@ export function createFollowUpFeatureHandler(autoModeService: AutoModeService) {
// Start follow-up in background
// followUpFeature derives workDir from feature.branchName
autoModeService
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? true)
// Default to false to match run-feature/resume-feature behavior.
// Worktrees should only be used when explicitly enabled by the user.
.followUpFeature(projectPath, featureId, prompt, imagePaths, useWorktrees ?? false)
.catch((error) => {
logger.error(`[AutoMode] Follow up feature ${featureId} error:`, error);
})

View File

@@ -232,7 +232,6 @@ File: ${fileName}${truncated ? ' (truncated)' : ''}`;
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
thinkingLevel, // Pass thinking level for extended thinking
});

View File

@@ -394,14 +394,13 @@ export function createDescribeImageHandler(
maxTurns: 1,
allowedTools: [],
autoLoadClaudeMd,
sandbox: { enabled: true, autoAllowBashIfSandboxed: true },
thinkingLevel, // Pass thinking level for extended thinking
});
logger.info(
`[${requestId}] SDK options model=${sdkOptions.model} maxTurns=${sdkOptions.maxTurns} allowedTools=${JSON.stringify(
sdkOptions.allowedTools
)} sandbox=${JSON.stringify(sdkOptions.sandbox)}`
)}`
);
const promptGenerator = (async function* () {

View File

@@ -11,9 +11,10 @@ import { getGitRepositoryDiffs } from '../../common.js';
export function createDiffsHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId } = req.body as {
const { projectPath, featureId, useWorktrees } = req.body as {
projectPath: string;
featureId: string;
useWorktrees?: boolean;
};
if (!projectPath || !featureId) {
@@ -24,6 +25,19 @@ export function createDiffsHandler() {
return;
}
// If worktrees aren't enabled, don't probe .worktrees at all.
// This avoids noisy logs that make it look like features are "running in worktrees".
if (useWorktrees === false) {
const result = await getGitRepositoryDiffs(projectPath);
res.json({
success: true,
diff: result.diff,
files: result.files,
hasChanges: result.hasChanges,
});
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
@@ -41,7 +55,11 @@ export function createDiffsHandler() {
});
} catch (innerError) {
// Worktree doesn't exist - fallback to main project path
logError(innerError, 'Worktree access failed, falling back to main project');
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
// ENOENT is expected when a feature has no worktree; don't log as an error.
if (code && code !== 'ENOENT') {
logError(innerError, 'Worktree access failed, falling back to main project');
}
try {
const result = await getGitRepositoryDiffs(projectPath);

View File

@@ -15,10 +15,11 @@ const execAsync = promisify(exec);
export function createFileDiffHandler() {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, filePath } = req.body as {
const { projectPath, featureId, filePath, useWorktrees } = req.body as {
projectPath: string;
featureId: string;
filePath: string;
useWorktrees?: boolean;
};
if (!projectPath || !featureId || !filePath) {
@@ -29,6 +30,12 @@ export function createFileDiffHandler() {
return;
}
// If worktrees aren't enabled, don't probe .worktrees at all.
if (useWorktrees === false) {
res.json({ success: true, diff: '', filePath });
return;
}
// Git worktrees are stored in project directory
const worktreePath = path.join(projectPath, '.worktrees', featureId);
@@ -57,7 +64,11 @@ export function createFileDiffHandler() {
res.json({ success: true, diff, filePath });
} catch (innerError) {
logError(innerError, 'Worktree file diff failed');
const code = (innerError as NodeJS.ErrnoException | undefined)?.code;
// ENOENT is expected when a feature has no worktree; don't log as an error.
if (code && code !== 'ENOENT') {
logError(innerError, 'Worktree file diff failed');
}
res.json({ success: true, diff: '', filePath });
}
} catch (error) {

View File

@@ -20,7 +20,6 @@ import { PathNotAllowedError } from '@automaker/platform';
import type { SettingsService } from './settings-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
@@ -232,12 +231,6 @@ export class AgentService {
'[AgentService]'
);
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(
this.settingsService,
'[AgentService]'
);
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
@@ -267,7 +260,6 @@ export class AgentService {
systemPrompt: combinedSystemPrompt,
abortController: session.abortController!,
autoLoadClaudeMd,
enableSandboxMode,
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
});
@@ -291,7 +283,6 @@ export class AgentService {
abortController: session.abortController!,
conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
};

View File

@@ -47,7 +47,6 @@ import type { SettingsService } from './settings-service.js';
import { pipelineService, PipelineService } from './pipeline-service.js';
import {
getAutoLoadClaudeMdSetting,
getEnableSandboxModeSetting,
filterClaudeMdFromContext,
getMCPServersFromSettings,
getPromptCustomization,
@@ -1314,7 +1313,6 @@ Format your response as a structured markdown document.`;
allowedTools: sdkOptions.allowedTools as string[],
abortController,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
thinkingLevel: analysisThinkingLevel, // Pass thinking level
};
@@ -1784,9 +1782,13 @@ Format your response as a structured markdown document.`;
// Apply dependency-aware ordering
const { orderedFeatures } = resolveDependencies(pendingFeatures);
// Get skipVerificationInAutoMode setting
const settings = await this.settingsService?.getGlobalSettings();
const skipVerification = settings?.skipVerificationInAutoMode ?? false;
// Filter to only features with satisfied dependencies
const readyFeatures = orderedFeatures.filter((feature: Feature) =>
areDependenciesSatisfied(feature, allFeatures)
areDependenciesSatisfied(feature, allFeatures, { skipVerification })
);
return readyFeatures;
@@ -2062,9 +2064,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
? options.autoLoadClaudeMd
: await getAutoLoadClaudeMdSetting(finalProjectPath, this.settingsService, '[AutoMode]');
// Load enableSandboxMode setting (global setting only)
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
// Load MCP servers from settings (global setting only)
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
@@ -2076,7 +2075,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
model: model,
abortController,
autoLoadClaudeMd,
enableSandboxMode,
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
thinkingLevel: options?.thinkingLevel,
});
@@ -2119,7 +2117,6 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
abortController,
systemPrompt: sdkOptions.systemPrompt,
settingSources: sdkOptions.settingSources,
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
thinkingLevel: options?.thinkingLevel, // Pass thinking level for extended thinking
};
@@ -2202,9 +2199,23 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
}, WRITE_DEBOUNCE_MS);
};
// Heartbeat logging so "silent" model calls are visible.
// Some runs can take a while before the first streamed message arrives.
const streamStartTime = Date.now();
let receivedAnyStreamMessage = false;
const STREAM_HEARTBEAT_MS = 15_000;
const streamHeartbeat = setInterval(() => {
if (receivedAnyStreamMessage) return;
const elapsedSeconds = Math.round((Date.now() - streamStartTime) / 1000);
logger.info(
`Waiting for first model response for feature ${featureId} (${elapsedSeconds}s elapsed)...`
);
}, STREAM_HEARTBEAT_MS);
// Wrap stream processing in try/finally to ensure timeout cleanup on any error/abort
try {
streamLoop: for await (const msg of stream) {
receivedAnyStreamMessage = true;
// Log raw stream event for debugging
appendRawEvent(msg);
@@ -2721,6 +2732,7 @@ Implement all the changes described in the plan above.`;
}
}
} finally {
clearInterval(streamHeartbeat);
// ALWAYS clear pending timeouts to prevent memory leaks
// This runs on success, error, or abort
if (writeTimeout) {

View File

@@ -153,14 +153,6 @@ export class SettingsService {
const storedVersion = settings.version || 1;
let needsSave = false;
// Migration v1 -> v2: Force enableSandboxMode to false for existing users
// Sandbox mode can cause issues on some systems, so we're disabling it by default
if (storedVersion < 2) {
logger.info('Migrating settings from v1 to v2: disabling sandbox mode');
result.enableSandboxMode = false;
needsSave = true;
}
// Migration v2 -> v3: Convert string phase models to PhaseModelEntry objects
// Note: migratePhaseModels() handles the actual conversion for both v1 and v2 formats
if (storedVersion < 3) {
@@ -537,6 +529,10 @@ export class SettingsService {
appState.enableDependencyBlocking !== undefined
? (appState.enableDependencyBlocking as boolean)
: true,
skipVerificationInAutoMode:
appState.skipVerificationInAutoMode !== undefined
? (appState.skipVerificationInAutoMode as boolean)
: false,
useWorktrees: (appState.useWorktrees as boolean) || false,
showProfilesOnly: (appState.showProfilesOnly as boolean) || false,
defaultPlanningMode:

View File

@@ -1,161 +1,15 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import os from 'os';
describe('sdk-options.ts', () => {
let originalEnv: NodeJS.ProcessEnv;
let homedirSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
originalEnv = { ...process.env };
vi.resetModules();
// Spy on os.homedir and set default return value
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue('/Users/test');
});
afterEach(() => {
process.env = originalEnv;
homedirSpy.mockRestore();
});
describe('isCloudStoragePath', () => {
it('should detect Dropbox paths on macOS', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox-Personal/project')).toBe(
true
);
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/Dropbox/project')).toBe(true);
});
it('should detect Google Drive paths on macOS', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(
isCloudStoragePath('/Users/test/Library/CloudStorage/GoogleDrive-user@gmail.com/project')
).toBe(true);
});
it('should detect OneDrive paths on macOS', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/Library/CloudStorage/OneDrive-Personal/project')).toBe(
true
);
});
it('should detect iCloud Drive paths on macOS', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(
isCloudStoragePath('/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project')
).toBe(true);
});
it('should detect home-anchored Dropbox paths', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/Dropbox')).toBe(true);
expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(true);
expect(isCloudStoragePath('/Users/test/Dropbox/nested/deep/project')).toBe(true);
});
it('should detect home-anchored Google Drive paths', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/Google Drive')).toBe(true);
expect(isCloudStoragePath('/Users/test/Google Drive/project')).toBe(true);
});
it('should detect home-anchored OneDrive paths', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/OneDrive')).toBe(true);
expect(isCloudStoragePath('/Users/test/OneDrive/project')).toBe(true);
});
it('should return false for local paths', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/projects/myapp')).toBe(false);
expect(isCloudStoragePath('/home/user/code/project')).toBe(false);
expect(isCloudStoragePath('/var/www/app')).toBe(false);
});
it('should return false for relative paths not in cloud storage', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('./project')).toBe(false);
expect(isCloudStoragePath('../other-project')).toBe(false);
});
// Tests for false positive prevention - paths that contain cloud storage names but aren't cloud storage
it('should NOT flag paths that merely contain "dropbox" in the name', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
// Projects with dropbox-like names
expect(isCloudStoragePath('/home/user/my-project-about-dropbox')).toBe(false);
expect(isCloudStoragePath('/Users/test/projects/dropbox-clone')).toBe(false);
expect(isCloudStoragePath('/Users/test/projects/Dropbox-backup-tool')).toBe(false);
// Dropbox folder that's NOT in the home directory
expect(isCloudStoragePath('/var/shared/Dropbox/project')).toBe(false);
});
it('should NOT flag paths that merely contain "Google Drive" in the name', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/projects/google-drive-api-client')).toBe(false);
expect(isCloudStoragePath('/home/user/Google Drive API Tests')).toBe(false);
});
it('should NOT flag paths that merely contain "OneDrive" in the name', async () => {
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
expect(isCloudStoragePath('/Users/test/projects/onedrive-sync-tool')).toBe(false);
expect(isCloudStoragePath('/home/user/OneDrive-migration-scripts')).toBe(false);
});
it('should handle different home directories correctly', async () => {
// Change the mocked home directory
homedirSpy.mockReturnValue('/home/linuxuser');
const { isCloudStoragePath } = await import('@/lib/sdk-options.js');
// Should detect Dropbox under the Linux home directory
expect(isCloudStoragePath('/home/linuxuser/Dropbox/project')).toBe(true);
// Should NOT detect Dropbox under the old home directory (since home changed)
expect(isCloudStoragePath('/Users/test/Dropbox/project')).toBe(false);
});
});
describe('checkSandboxCompatibility', () => {
it('should return enabled=false when user disables sandbox', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility('/Users/test/project', false);
expect(result.enabled).toBe(false);
expect(result.disabledReason).toBe('user_setting');
});
it('should return enabled=false for cloud storage paths even when sandbox enabled', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility(
'/Users/test/Library/CloudStorage/Dropbox-Personal/project',
true
);
expect(result.enabled).toBe(false);
expect(result.disabledReason).toBe('cloud_storage');
expect(result.message).toContain('cloud storage');
});
it('should return enabled=true for local paths when sandbox enabled', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility('/Users/test/projects/myapp', true);
expect(result.enabled).toBe(true);
expect(result.disabledReason).toBeUndefined();
});
it('should return enabled=true when enableSandboxMode is undefined for local paths', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility('/Users/test/project', undefined);
expect(result.enabled).toBe(true);
expect(result.disabledReason).toBeUndefined();
});
it('should return enabled=false for cloud storage paths when enableSandboxMode is undefined', async () => {
const { checkSandboxCompatibility } = await import('@/lib/sdk-options.js');
const result = checkSandboxCompatibility(
'/Users/test/Library/CloudStorage/Dropbox-Personal/project',
undefined
);
expect(result.enabled).toBe(false);
expect(result.disabledReason).toBe('cloud_storage');
});
});
describe('TOOL_PRESETS', () => {
@@ -325,19 +179,15 @@ describe('sdk-options.ts', () => {
it('should create options with chat settings', async () => {
const { createChatOptions, TOOL_PRESETS, MAX_TURNS } = await import('@/lib/sdk-options.js');
const options = createChatOptions({ cwd: '/test/path', enableSandboxMode: true });
const options = createChatOptions({ cwd: '/test/path' });
expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.standard);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.chat]);
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: true,
});
});
it('should prefer explicit model over session model', async () => {
const { createChatOptions, getModelForUseCase } = await import('@/lib/sdk-options.js');
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
@@ -358,41 +208,6 @@ describe('sdk-options.ts', () => {
expect(options.model).toBe('claude-sonnet-4-20250514');
});
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should enable sandbox by default when enableSandboxMode is not provided', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/test/path',
});
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: true,
});
});
it('should auto-disable sandbox for cloud storage paths', async () => {
const { createChatOptions } = await import('@/lib/sdk-options.js');
const options = createChatOptions({
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
enableSandboxMode: true,
});
expect(options.sandbox).toBeUndefined();
});
});
describe('createAutoModeOptions', () => {
@@ -400,15 +215,11 @@ describe('sdk-options.ts', () => {
const { createAutoModeOptions, TOOL_PRESETS, MAX_TURNS } =
await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({ cwd: '/test/path', enableSandboxMode: true });
const options = createAutoModeOptions({ cwd: '/test/path' });
expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(MAX_TURNS.maximum);
expect(options.allowedTools).toEqual([...TOOL_PRESETS.fullAccess]);
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: true,
});
});
it('should include systemPrompt when provided', async () => {
@@ -433,62 +244,6 @@ describe('sdk-options.ts', () => {
expect(options.abortController).toBe(abortController);
});
it('should not set sandbox when enableSandboxMode is false', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
enableSandboxMode: false,
});
expect(options.sandbox).toBeUndefined();
});
it('should enable sandbox by default when enableSandboxMode is not provided', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/test/path',
});
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: true,
});
});
it('should auto-disable sandbox for cloud storage paths', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
enableSandboxMode: true,
});
expect(options.sandbox).toBeUndefined();
});
it('should auto-disable sandbox for cloud storage paths even when enableSandboxMode is not provided', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/Users/test/Library/CloudStorage/Dropbox-Personal/project',
});
expect(options.sandbox).toBeUndefined();
});
it('should auto-disable sandbox for iCloud paths', async () => {
const { createAutoModeOptions } = await import('@/lib/sdk-options.js');
const options = createAutoModeOptions({
cwd: '/Users/test/Library/Mobile Documents/com~apple~CloudDocs/project',
enableSandboxMode: true,
});
expect(options.sandbox).toBeUndefined();
});
});
describe('createCustomOptions', () => {
@@ -499,13 +254,11 @@ describe('sdk-options.ts', () => {
cwd: '/test/path',
maxTurns: 10,
allowedTools: ['Read', 'Write'],
sandbox: { enabled: true },
});
expect(options.cwd).toBe('/test/path');
expect(options.maxTurns).toBe(10);
expect(options.allowedTools).toEqual(['Read', 'Write']);
expect(options.sandbox).toEqual({ enabled: true });
});
it('should use defaults when optional params not provided', async () => {
@@ -517,20 +270,6 @@ describe('sdk-options.ts', () => {
expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]);
});
it('should include sandbox when provided', async () => {
const { createCustomOptions } = await import('@/lib/sdk-options.js');
const options = createCustomOptions({
cwd: '/test/path',
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
});
expect(options.sandbox).toEqual({
enabled: true,
autoAllowBashIfSandboxed: false,
});
});
it('should include systemPrompt when provided', async () => {
const { createCustomOptions } = await import('@/lib/sdk-options.js');

View File

@@ -79,7 +79,7 @@ describe('claude-provider.ts', () => {
});
});
it('should use default allowed tools when not specified', async () => {
it('should not include allowedTools when not specified (caller decides via sdk-options)', async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
@@ -95,37 +95,8 @@ describe('claude-provider.ts', () => {
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test',
options: expect.objectContaining({
allowedTools: ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
}),
});
});
it('should pass sandbox configuration when provided', async () => {
vi.mocked(sdk.query).mockReturnValue(
(async function* () {
yield { type: 'text', text: 'test' };
})()
);
const generator = provider.executeQuery({
prompt: 'Test',
cwd: '/test',
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
});
await collectAsyncGenerator(generator);
expect(sdk.query).toHaveBeenCalledWith({
prompt: 'Test',
options: expect.objectContaining({
sandbox: {
enabled: true,
autoAllowBashIfSandboxed: true,
},
options: expect.not.objectContaining({
allowedTools: expect.anything(),
}),
});
});

View File

@@ -3,6 +3,4 @@ export { DeleteAllArchivedSessionsDialog } from './delete-all-archived-sessions-
export { DeleteSessionDialog } from './delete-session-dialog';
export { FileBrowserDialog } from './file-browser-dialog';
export { NewProjectModal } from './new-project-modal';
export { SandboxRejectionScreen } from './sandbox-rejection-screen';
export { SandboxRiskDialog } from './sandbox-risk-dialog';
export { WorkspacePickerModal } from './workspace-picker-modal';

View File

@@ -1,93 +0,0 @@
/**
* Sandbox Rejection Screen
*
* Shown in web mode when user denies the sandbox risk confirmation.
* Prompts them to either restart the app in a container or reload to try again.
*/
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ShieldX, RefreshCw, Container, Copy, Check } from 'lucide-react';
const logger = createLogger('SandboxRejectionScreen');
import { Button } from '@/components/ui/button';
const DOCKER_COMMAND = 'npm run dev:docker';
export function SandboxRejectionScreen() {
const [copied, setCopied] = useState(false);
const handleReload = () => {
// Clear the rejection state and reload
sessionStorage.removeItem('automaker-sandbox-denied');
window.location.reload();
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(DOCKER_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
logger.error('Failed to copy:', err);
}
};
return (
<div className="min-h-screen bg-background flex items-center justify-center p-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="flex justify-center">
<div className="rounded-full bg-destructive/10 p-4">
<ShieldX className="w-12 h-12 text-destructive" />
</div>
</div>
<div className="space-y-2">
<h1 className="text-2xl font-semibold">Access Denied</h1>
<p className="text-muted-foreground">
You declined to accept the risks of running Automaker outside a sandbox environment.
</p>
</div>
<div className="bg-muted/50 border border-border rounded-lg p-4 text-left space-y-3">
<div className="flex items-start gap-3">
<Container className="w-5 h-5 mt-0.5 text-primary flex-shrink-0" />
<div className="flex-1 space-y-2">
<p className="font-medium text-sm">Run in Docker (Recommended)</p>
<p className="text-sm text-muted-foreground">
Run Automaker in a containerized sandbox environment:
</p>
<div className="flex items-center gap-2 bg-background border border-border rounded-lg p-2">
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8 px-2 hover:bg-muted"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
</div>
<div className="pt-2">
<Button
variant="outline"
onClick={handleReload}
className="gap-2"
data-testid="sandbox-retry"
>
<RefreshCw className="w-4 h-4" />
Reload &amp; Try Again
</Button>
</div>
</div>
</div>
);
}

View File

@@ -1,140 +0,0 @@
/**
* Sandbox Risk Confirmation Dialog
*
* Shows when the app is running outside a containerized environment.
* Users must acknowledge the risks before proceeding.
*/
import { useState } from 'react';
import { createLogger } from '@automaker/utils/logger';
import { ShieldAlert, Copy, Check } from 'lucide-react';
const logger = createLogger('SandboxRiskDialog');
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
interface SandboxRiskDialogProps {
open: boolean;
onConfirm: (skipInFuture: boolean) => void;
onDeny: () => void;
}
const DOCKER_COMMAND = 'npm run dev:docker';
export function SandboxRiskDialog({ open, onConfirm, onDeny }: SandboxRiskDialogProps) {
const [copied, setCopied] = useState(false);
const [skipInFuture, setSkipInFuture] = useState(false);
const handleConfirm = () => {
onConfirm(skipInFuture);
// Reset checkbox state after confirmation
setSkipInFuture(false);
};
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(DOCKER_COMMAND);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
logger.error('Failed to copy:', err);
}
};
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent
className="bg-popover border-border max-w-lg"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
showCloseButton={false}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-destructive">
<ShieldAlert className="w-6 h-6" />
Sandbox Environment Not Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4 pt-2">
<p className="text-muted-foreground">
<strong>Warning:</strong> This application is running outside of a containerized
sandbox environment. AI agents will have direct access to your filesystem and can
execute commands on your system.
</p>
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium text-destructive">Potential Risks:</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>Agents can read, modify, or delete files on your system</li>
<li>Agents can execute arbitrary commands and install software</li>
<li>Agents can access environment variables and credentials</li>
<li>Unintended side effects from agent actions may affect your system</li>
</ul>
</div>
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
For safer operation, consider running Automaker in Docker:
</p>
<div className="flex items-center gap-2 bg-muted/50 border border-border rounded-lg p-2">
<code className="flex-1 text-sm font-mono px-2">{DOCKER_COMMAND}</code>
<Button
variant="ghost"
size="sm"
onClick={handleCopy}
className="h-8 px-2 hover:bg-muted"
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
</div>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex-col gap-4 sm:flex-col pt-4">
<div className="flex items-center space-x-2 self-start">
<Checkbox
id="skip-sandbox-warning"
checked={skipInFuture}
onCheckedChange={(checked) => setSkipInFuture(checked === true)}
data-testid="sandbox-skip-checkbox"
/>
<Label
htmlFor="skip-sandbox-warning"
className="text-sm text-muted-foreground cursor-pointer"
>
Do not show this warning again
</Label>
</div>
<div className="flex gap-2 sm:gap-2 w-full sm:justify-end">
<Button variant="outline" onClick={onDeny} className="px-4" data-testid="sandbox-deny">
Deny &amp; Exit
</Button>
<Button
variant="destructive"
onClick={handleConfirm}
className="px-4"
data-testid="sandbox-confirm"
>
<ShieldAlert className="w-4 h-4 mr-2" />I Accept the Risks
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -89,6 +89,7 @@ export function BoardView() {
setWorktrees,
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
setPipelineConfig,
@@ -732,10 +733,17 @@ export function BoardView() {
}, []);
useEffect(() => {
logger.info(
'[AutoMode] Effect triggered - isRunning:',
autoMode.isRunning,
'hasProject:',
!!currentProject
);
if (!autoMode.isRunning || !currentProject) {
return;
}
logger.info('[AutoMode] Starting auto mode polling loop for project:', currentProject.path);
let isChecking = false;
let isActive = true; // Track if this effect is still active
@@ -755,6 +763,14 @@ export function BoardView() {
try {
// Double-check auto mode is still running before proceeding
if (!isActive || !autoModeRunningRef.current || !currentProject) {
logger.debug(
'[AutoMode] Skipping check - isActive:',
isActive,
'autoModeRunning:',
autoModeRunningRef.current,
'hasProject:',
!!currentProject
);
return;
}
@@ -762,6 +778,12 @@ export function BoardView() {
// Use ref to get the latest running tasks without causing effect re-runs
const currentRunning = runningAutoTasksRef.current.length + pendingFeaturesRef.current.size;
const availableSlots = maxConcurrency - currentRunning;
logger.debug(
'[AutoMode] Checking features - running:',
currentRunning,
'available slots:',
availableSlots
);
// No available slots, skip check
if (availableSlots <= 0) {
@@ -769,10 +791,12 @@ export function BoardView() {
}
// Filter backlog features by the currently selected worktree branch
// This logic mirrors use-board-column-features.ts for consistency
// This logic mirrors use-board-column-features.ts for consistency.
// HOWEVER: auto mode should still run even if the user is viewing a non-primary worktree,
// so we fall back to "all backlog features" when none are visible in the current view.
// Use ref to get the latest features without causing effect re-runs
const currentFeatures = hookFeaturesRef.current;
const backlogFeatures = currentFeatures.filter((f) => {
const backlogFeaturesInView = currentFeatures.filter((f) => {
if (f.status !== 'backlog') return false;
const featureBranch = f.branchName;
@@ -796,7 +820,25 @@ export function BoardView() {
return featureBranch === currentWorktreeBranch;
});
const backlogFeatures =
backlogFeaturesInView.length > 0
? backlogFeaturesInView
: currentFeatures.filter((f) => f.status === 'backlog');
logger.debug(
'[AutoMode] Features - total:',
currentFeatures.length,
'backlog in view:',
backlogFeaturesInView.length,
'backlog total:',
backlogFeatures.length
);
if (backlogFeatures.length === 0) {
logger.debug(
'[AutoMode] No backlog features found, statuses:',
currentFeatures.map((f) => f.status).join(', ')
);
return;
}
@@ -806,12 +848,25 @@ export function BoardView() {
);
// Filter out features with blocking dependencies if dependency blocking is enabled
const eligibleFeatures = enableDependencyBlocking
? sortedBacklog.filter((f) => {
const blockingDeps = getBlockingDependencies(f, currentFeatures);
return blockingDeps.length === 0;
})
: sortedBacklog;
// NOTE: skipVerificationInAutoMode means "ignore unmet dependency verification" so we
// should NOT exclude blocked features in that mode.
const eligibleFeatures =
enableDependencyBlocking && !skipVerificationInAutoMode
? sortedBacklog.filter((f) => {
const blockingDeps = getBlockingDependencies(f, currentFeatures);
if (blockingDeps.length > 0) {
logger.debug('[AutoMode] Feature', f.id, 'blocked by deps:', blockingDeps);
}
return blockingDeps.length === 0;
})
: sortedBacklog;
logger.debug(
'[AutoMode] Eligible features after dep check:',
eligibleFeatures.length,
'dependency blocking enabled:',
enableDependencyBlocking
);
// Start features up to available slots
const featuresToStart = eligibleFeatures.slice(0, availableSlots);
@@ -820,6 +875,13 @@ export function BoardView() {
return;
}
logger.info(
'[AutoMode] Starting',
featuresToStart.length,
'features:',
featuresToStart.map((f) => f.id).join(', ')
);
for (const feature of featuresToStart) {
// Check again before starting each feature
if (!isActive || !autoModeRunningRef.current || !currentProject) {
@@ -827,8 +889,9 @@ export function BoardView() {
}
// Simplified: No worktree creation on client - server derives workDir from feature.branchName
// If feature has no branchName and primary worktree is selected, assign primary branch
if (currentWorktreePath === null && !feature.branchName) {
// If feature has no branchName, assign it to the primary branch so it can run consistently
// even when the user is viewing a non-primary worktree.
if (!feature.branchName) {
const primaryBranch =
(currentProject.path ? getPrimaryWorktreeBranch(currentProject.path) : null) ||
'main';
@@ -878,6 +941,7 @@ export function BoardView() {
getPrimaryWorktreeBranch,
isPrimaryWorktreeBranch,
enableDependencyBlocking,
skipVerificationInAutoMode,
persistFeatureUpdate,
]);

View File

@@ -1,13 +1,15 @@
import { useState } from 'react';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plus, Bot, Wand2 } from 'lucide-react';
import { Plus, Bot, Wand2, Settings2 } from 'lucide-react';
import { KeyboardShortcut } from '@/hooks/use-keyboard-shortcuts';
import { ClaudeUsagePopover } from '@/components/claude-usage-popover';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { AutoModeSettingsDialog } from './dialogs/auto-mode-settings-dialog';
interface BoardHeaderProps {
projectName: string;
@@ -38,8 +40,11 @@ export function BoardHeader({
addFeatureShortcut,
isMounted,
}: BoardHeaderProps) {
const [showAutoModeSettings, setShowAutoModeSettings] = 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);
// Hide usage tracking when using API key (only show for Claude Code CLI users)
// Check both user-entered API key and environment variable ANTHROPIC_API_KEY
@@ -97,9 +102,25 @@ export function BoardHeader({
onCheckedChange={onAutoModeToggle}
data-testid="auto-mode-toggle"
/>
<button
onClick={() => setShowAutoModeSettings(true)}
className="p-1 rounded hover:bg-accent/50 transition-colors"
title="Auto Mode Settings"
data-testid="auto-mode-settings-button"
>
<Settings2 className="w-4 h-4 text-muted-foreground" />
</button>
</div>
)}
{/* Auto Mode Settings Dialog */}
<AutoModeSettingsDialog
open={showAutoModeSettings}
onOpenChange={setShowAutoModeSettings}
skipVerificationInAutoMode={skipVerificationInAutoMode}
onSkipVerificationChange={setSkipVerificationInAutoMode}
/>
<Button
size="sm"
variant="outline"

View File

@@ -5,118 +5,44 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { AlertCircle, Lock, Hand, Sparkles } from 'lucide-react';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
interface CardBadgeProps {
children: React.ReactNode;
className?: string;
'data-testid'?: string;
title?: string;
}
/**
* Shared badge component matching the "Just Finished" badge style
* Used for priority badges and other card badges
*/
function CardBadge({ children, className, 'data-testid': dataTestId, title }: CardBadgeProps) {
return (
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
className
)}
data-testid={dataTestId}
title={title}
>
{children}
</div>
);
}
/** Uniform badge style for all card badges */
const uniformBadgeClass =
'inline-flex items-center justify-center w-6 h-6 rounded-md border-[1.5px]';
interface CardBadgesProps {
feature: Feature;
}
/**
* CardBadges - Shows error badges below the card header
* Note: Blocked/Lock badges are now shown in PriorityBadges for visual consistency
*/
export function CardBadges({ feature }: CardBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore();
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
return [];
}
return getBlockingDependencies(feature, features);
}, [enableDependencyBlocking, feature, features]);
// Status badges row (error, blocked)
const showStatusBadges =
feature.error ||
(blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === 'backlog');
if (!showStatusBadges) {
if (!feature.error) {
return null;
}
return (
<div className="flex flex-wrap items-center gap-1.5 px-3 pt-1.5 min-h-[24px]">
{/* Error badge */}
{feature.error && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[10px] font-medium',
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Blocked badge */}
{blockingDependencies.length > 0 &&
!feature.error &&
!feature.skipTests &&
feature.status === 'backlog' && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'inline-flex items-center gap-1 rounded-full border-2 px-1.5 py-0.5 text-[10px] font-bold',
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-3 h-3" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
.map((depId) => {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]'
)}
data-testid={`error-badge-${feature.id}`}
>
<AlertCircle className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p>{feature.error}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@@ -126,8 +52,17 @@ interface PriorityBadgesProps {
}
export function PriorityBadges({ feature }: PriorityBadgesProps) {
const { enableDependencyBlocking, features } = useAppStore();
const [currentTime, setCurrentTime] = useState(() => Date.now());
// Calculate blocking dependencies (if feature is in backlog and has incomplete dependencies)
const blockingDependencies = useMemo(() => {
if (!enableDependencyBlocking || feature.status !== 'backlog') {
return [];
}
return getBlockingDependencies(feature, features);
}, [enableDependencyBlocking, feature, features]);
const isJustFinished = useMemo(() => {
if (!feature.justFinishedAt || feature.status !== 'waiting_approval' || feature.error) {
return false;
@@ -161,25 +96,27 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
};
}, [feature.justFinishedAt, feature.status, currentTime]);
const showPriorityBadges =
feature.priority ||
(feature.skipTests && !feature.error && feature.status === 'backlog') ||
isJustFinished;
const isBlocked =
blockingDependencies.length > 0 && !feature.error && feature.status === 'backlog';
const showManualVerification =
feature.skipTests && !feature.error && feature.status === 'backlog';
if (!showPriorityBadges) {
const showBadges = feature.priority || showManualVerification || isBlocked || isJustFinished;
if (!showBadges) {
return null;
}
return (
<div className="absolute top-2 left-2 flex items-center gap-1.5">
<div className="absolute top-2 left-2 flex items-center gap-1">
{/* Priority badge */}
{feature.priority && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<CardBadge
<div
className={cn(
'bg-opacity-90 border rounded-[6px] px-1.5 py-0.5 flex items-center justify-center border-[1.5px] w-5 h-5', // badge style from example
uniformBadgeClass,
feature.priority === 1 &&
'bg-[var(--status-error-bg)] border-[var(--status-error)]/40 text-[var(--status-error)]',
feature.priority === 2 &&
@@ -189,14 +126,10 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
)}
data-testid={`priority-badge-${feature.id}`}
>
{feature.priority === 1 ? (
<span className="font-bold text-xs flex items-center gap-0.5">H</span>
) : feature.priority === 2 ? (
<span className="font-bold text-xs flex items-center gap-0.5">M</span>
) : (
<span className="font-bold text-xs flex items-center gap-0.5">L</span>
)}
</CardBadge>
<span className="font-bold text-xs">
{feature.priority === 1 ? 'H' : feature.priority === 2 ? 'M' : 'L'}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>
@@ -210,17 +143,21 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
</Tooltip>
</TooltipProvider>
)}
{/* Manual verification badge */}
{feature.skipTests && !feature.error && feature.status === 'backlog' && (
{showManualVerification && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<CardBadge
className="bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]"
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-warning-bg)] border-[var(--status-warning)]/40 text-[var(--status-warning)]'
)}
data-testid={`skip-tests-badge-${feature.id}`}
>
<Hand className="w-3 h-3" />
</CardBadge>
<Hand className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Manual verification required</p>
@@ -229,15 +166,59 @@ export function PriorityBadges({ feature }: PriorityBadgesProps) {
</TooltipProvider>
)}
{/* Blocked badge */}
{isBlocked && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-orange-500/20 border-orange-500/50 text-orange-500'
)}
data-testid={`blocked-badge-${feature.id}`}
>
<Lock className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-[250px]">
<p className="font-medium mb-1">
Blocked by {blockingDependencies.length} incomplete{' '}
{blockingDependencies.length === 1 ? 'dependency' : 'dependencies'}
</p>
<p className="text-muted-foreground">
{blockingDependencies
.map((depId) => {
const dep = features.find((f) => f.id === depId);
return dep?.description || depId;
})
.join(', ')}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Just Finished badge */}
{isJustFinished && (
<CardBadge
className="bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse"
data-testid={`just-finished-badge-${feature.id}`}
title="Agent just finished working on this feature"
>
<Sparkles className="w-3 h-3" />
</CardBadge>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
uniformBadgeClass,
'bg-[var(--status-success-bg)] border-[var(--status-success)]/40 text-[var(--status-success)] animate-pulse'
)}
data-testid={`just-finished-badge-${feature.id}`}
>
<Sparkles className="w-3.5 h-3.5" />
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>Agent just finished working on this feature</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);

View File

@@ -0,0 +1,68 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { FastForward, Settings2 } from 'lucide-react';
interface AutoModeSettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
skipVerificationInAutoMode: boolean;
onSkipVerificationChange: (value: boolean) => void;
}
export function AutoModeSettingsDialog({
open,
onOpenChange,
skipVerificationInAutoMode,
onSkipVerificationChange,
}: AutoModeSettingsDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md" data-testid="auto-mode-settings-dialog">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Settings2 className="w-5 h-5" />
Auto Mode Settings
</DialogTitle>
<DialogDescription>
Configure how auto mode handles feature execution and dependencies.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Skip Verification Setting */}
<div className="flex items-start space-x-3 p-3 rounded-lg bg-secondary/50">
<div className="flex-1 space-y-1">
<div className="flex items-center justify-between">
<Label
htmlFor="skip-verification-toggle"
className="text-sm font-medium cursor-pointer flex items-center gap-2"
>
<FastForward className="w-4 h-4 text-brand-500" />
Skip verification requirement
</Label>
<Switch
id="skip-verification-toggle"
checked={skipVerificationInAutoMode}
onCheckedChange={onSkipVerificationChange}
data-testid="skip-verification-toggle"
/>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
When enabled, auto mode will grab features even if their dependencies are not
verified, as long as they are not currently running. This allows faster pipeline
execution without waiting for manual verification.
</p>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -79,6 +79,7 @@ export function useBoardActions({
moveFeature,
useWorktrees,
enableDependencyBlocking,
skipVerificationInAutoMode,
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
} = useAppStore();
@@ -805,12 +806,14 @@ export function useBoardActions({
// Sort by priority (lower number = higher priority, priority 1 is highest)
// Features with blocking dependencies are sorted to the end
const sortedBacklog = [...backlogFeatures].sort((a, b) => {
const aBlocked = enableDependencyBlocking
? getBlockingDependencies(a, features).length > 0
: false;
const bBlocked = enableDependencyBlocking
? getBlockingDependencies(b, features).length > 0
: false;
const aBlocked =
enableDependencyBlocking && !skipVerificationInAutoMode
? getBlockingDependencies(a, features).length > 0
: false;
const bBlocked =
enableDependencyBlocking && !skipVerificationInAutoMode
? getBlockingDependencies(b, features).length > 0
: false;
// Blocked features go to the end
if (aBlocked && !bBlocked) return 1;
@@ -822,14 +825,14 @@ export function useBoardActions({
// Find the first feature without blocking dependencies
const featureToStart = sortedBacklog.find((f) => {
if (!enableDependencyBlocking) return true;
if (!enableDependencyBlocking || skipVerificationInAutoMode) return true;
return getBlockingDependencies(f, features).length === 0;
});
if (!featureToStart) {
toast.info('No eligible features', {
description:
'All backlog features have unmet dependencies. Complete their dependencies first.',
'All backlog features have unmet dependencies. Complete their dependencies first (or enable "Skip verification requirement" in Auto Mode settings).',
});
return;
}
@@ -846,6 +849,7 @@ export function useBoardActions({
isPrimaryWorktreeBranch,
getPrimaryWorktreeBranch,
enableDependencyBlocking,
skipVerificationInAutoMode,
]);
const handleArchiveAllVerified = useCallback(async () => {

View File

@@ -31,6 +31,8 @@ export function SettingsView() {
setDefaultSkipTests,
enableDependencyBlocking,
setEnableDependencyBlocking,
skipVerificationInAutoMode,
setSkipVerificationInAutoMode,
useWorktrees,
setUseWorktrees,
showProfilesOnly,
@@ -48,10 +50,6 @@ export function SettingsView() {
aiProfiles,
autoLoadClaudeMd,
setAutoLoadClaudeMd,
enableSandboxMode,
setEnableSandboxMode,
skipSandboxWarning,
setSkipSandboxWarning,
promptCustomization,
setPromptCustomization,
} = useAppStore();
@@ -130,6 +128,7 @@ export function SettingsView() {
showProfilesOnly={showProfilesOnly}
defaultSkipTests={defaultSkipTests}
enableDependencyBlocking={enableDependencyBlocking}
skipVerificationInAutoMode={skipVerificationInAutoMode}
useWorktrees={useWorktrees}
defaultPlanningMode={defaultPlanningMode}
defaultRequirePlanApproval={defaultRequirePlanApproval}
@@ -138,6 +137,7 @@ export function SettingsView() {
onShowProfilesOnlyChange={setShowProfilesOnly}
onDefaultSkipTestsChange={setDefaultSkipTests}
onEnableDependencyBlockingChange={setEnableDependencyBlocking}
onSkipVerificationInAutoModeChange={setSkipVerificationInAutoMode}
onUseWorktreesChange={setUseWorktrees}
onDefaultPlanningModeChange={setDefaultPlanningMode}
onDefaultRequirePlanApprovalChange={setDefaultRequirePlanApproval}
@@ -149,8 +149,6 @@ export function SettingsView() {
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
skipSandboxWarning={skipSandboxWarning}
onResetSandboxWarning={() => setSkipSandboxWarning(false)}
/>
);
default:

View File

@@ -1,13 +1,11 @@
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { FileCode, Shield } from 'lucide-react';
import { FileCode } from 'lucide-react';
import { cn } from '@/lib/utils';
interface ClaudeMdSettingsProps {
autoLoadClaudeMd: boolean;
onAutoLoadClaudeMdChange: (enabled: boolean) => void;
enableSandboxMode: boolean;
onEnableSandboxModeChange: (enabled: boolean) => void;
}
/**
@@ -15,23 +13,18 @@ interface ClaudeMdSettingsProps {
*
* UI controls for Claude Agent SDK settings including:
* - Auto-loading of project instructions from .claude/CLAUDE.md files
* - Sandbox mode for isolated bash command execution
*
* Usage:
* ```tsx
* <ClaudeMdSettings
* autoLoadClaudeMd={autoLoadClaudeMd}
* onAutoLoadClaudeMdChange={setAutoLoadClaudeMd}
* enableSandboxMode={enableSandboxMode}
* onEnableSandboxModeChange={setEnableSandboxMode}
* />
* ```
*/
export function ClaudeMdSettings({
autoLoadClaudeMd,
onAutoLoadClaudeMdChange,
enableSandboxMode,
onEnableSandboxModeChange,
}: ClaudeMdSettingsProps) {
return (
<div
@@ -83,32 +76,6 @@ export function ClaudeMdSettings({
</p>
</div>
</div>
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3 mt-2">
<Checkbox
id="enable-sandbox-mode"
checked={enableSandboxMode}
onCheckedChange={(checked) => onEnableSandboxModeChange(checked === true)}
className="mt-1"
data-testid="enable-sandbox-mode-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="enable-sandbox-mode"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Shield className="w-4 h-4 text-brand-500" />
Enable Sandbox Mode
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Run bash commands in an isolated sandbox environment for additional security.
<span className="block mt-1 text-warning/80">
Note: On some systems, enabling sandbox mode may cause the agent to hang without
responding. If you experience issues, try disabling this option.
</span>
</p>
</div>
</div>
</div>
</div>
);

View File

@@ -1,21 +1,14 @@
import { Button } from '@/components/ui/button';
import { Trash2, Folder, AlertTriangle, Shield, RotateCcw } from 'lucide-react';
import { Trash2, Folder, AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Project } from '../shared/types';
interface DangerZoneSectionProps {
project: Project | null;
onDeleteClick: () => void;
skipSandboxWarning: boolean;
onResetSandboxWarning: () => void;
}
export function DangerZoneSection({
project,
onDeleteClick,
skipSandboxWarning,
onResetSandboxWarning,
}: DangerZoneSectionProps) {
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) {
return (
<div
className={cn(
@@ -37,36 +30,6 @@ export function DangerZoneSection({
</p>
</div>
<div className="p-6 space-y-4">
{/* Sandbox Warning Reset */}
{skipSandboxWarning && (
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-destructive/15 to-destructive/10 border border-destructive/20 flex items-center justify-center shrink-0">
<Shield className="w-5 h-5 text-destructive" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground">Sandbox Warning Disabled</p>
<p className="text-xs text-muted-foreground/70 mt-0.5">
The sandbox environment warning is hidden on startup
</p>
</div>
</div>
<Button
variant="outline"
onClick={onResetSandboxWarning}
data-testid="reset-sandbox-warning-button"
className={cn(
'shrink-0 gap-2',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<RotateCcw className="w-4 h-4" />
Reset
</Button>
</div>
)}
{/* Project Delete */}
{project && (
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
@@ -97,7 +60,7 @@ export function DangerZoneSection({
)}
{/* Empty state when nothing to show */}
{!skipSandboxWarning && !project && (
{!project && (
<p className="text-sm text-muted-foreground/60 text-center py-4">
No danger zone actions available.
</p>

View File

@@ -12,6 +12,7 @@ import {
ScrollText,
ShieldCheck,
User,
FastForward,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
@@ -29,6 +30,7 @@ interface FeatureDefaultsSectionProps {
showProfilesOnly: boolean;
defaultSkipTests: boolean;
enableDependencyBlocking: boolean;
skipVerificationInAutoMode: boolean;
useWorktrees: boolean;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
@@ -37,6 +39,7 @@ interface FeatureDefaultsSectionProps {
onShowProfilesOnlyChange: (value: boolean) => void;
onDefaultSkipTestsChange: (value: boolean) => void;
onEnableDependencyBlockingChange: (value: boolean) => void;
onSkipVerificationInAutoModeChange: (value: boolean) => void;
onUseWorktreesChange: (value: boolean) => void;
onDefaultPlanningModeChange: (value: PlanningMode) => void;
onDefaultRequirePlanApprovalChange: (value: boolean) => void;
@@ -47,6 +50,7 @@ export function FeatureDefaultsSection({
showProfilesOnly,
defaultSkipTests,
enableDependencyBlocking,
skipVerificationInAutoMode,
useWorktrees,
defaultPlanningMode,
defaultRequirePlanApproval,
@@ -55,6 +59,7 @@ export function FeatureDefaultsSection({
onShowProfilesOnlyChange,
onDefaultSkipTestsChange,
onEnableDependencyBlockingChange,
onSkipVerificationInAutoModeChange,
onUseWorktreesChange,
onDefaultPlanningModeChange,
onDefaultRequirePlanApprovalChange,
@@ -309,6 +314,34 @@ export function FeatureDefaultsSection({
{/* Separator */}
<div className="border-t border-border/30" />
{/* Skip Verification in Auto Mode Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="skip-verification-auto-mode"
checked={skipVerificationInAutoMode}
onCheckedChange={(checked) => onSkipVerificationInAutoModeChange(checked === true)}
className="mt-1"
data-testid="skip-verification-auto-mode-checkbox"
/>
<div className="space-y-1.5">
<Label
htmlFor="skip-verification-auto-mode"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<FastForward className="w-4 h-4 text-brand-500" />
Skip verification in auto mode
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When enabled, auto mode will grab features even if their dependencies are not
verified, as long as they are not currently running. This allows faster pipeline
execution without waiting for manual verification.
</p>
</div>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Worktree Isolation Setting */}
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox

View File

@@ -7,6 +7,36 @@ import type { AutoModeEvent } from '@/types/electron';
const logger = createLogger('AutoMode');
const AUTO_MODE_SESSION_KEY = 'automaker:autoModeRunningByProjectPath';
function readAutoModeSession(): Record<string, boolean> {
try {
if (typeof window === 'undefined') return {};
const raw = window.sessionStorage?.getItem(AUTO_MODE_SESSION_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') return {};
return parsed as Record<string, boolean>;
} catch {
return {};
}
}
function writeAutoModeSession(next: Record<string, boolean>): void {
try {
if (typeof window === 'undefined') return;
window.sessionStorage?.setItem(AUTO_MODE_SESSION_KEY, JSON.stringify(next));
} catch {
// ignore storage errors (private mode, disabled storage, etc.)
}
}
function setAutoModeSessionForProjectPath(projectPath: string, running: boolean): void {
const current = readAutoModeSession();
const next = { ...current, [projectPath]: running };
writeAutoModeSession(next);
}
// Type guard for plan_approval_required event
function isPlanApprovalEvent(
event: AutoModeEvent
@@ -64,6 +94,23 @@ export function useAutoMode() {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Restore auto-mode toggle after a renderer refresh (e.g. dev HMR reload).
// This is intentionally session-scoped to avoid auto-running features after a full app restart.
useEffect(() => {
if (!currentProject) return;
const session = readAutoModeSession();
const desired = session[currentProject.path];
if (typeof desired !== 'boolean') return;
if (desired !== isAutoModeRunning) {
logger.info(
`[AutoMode] Restoring session state for ${currentProject.path}: ${desired ? 'ON' : 'OFF'}`
);
setAutoModeRunning(currentProject.id, desired);
}
}, [currentProject, isAutoModeRunning, setAutoModeRunning]);
// Handle auto mode events - listen globally for all projects
useEffect(() => {
const api = getElectronAPI();
@@ -337,6 +384,7 @@ export function useAutoMode() {
return;
}
setAutoModeSessionForProjectPath(currentProject.path, true);
setAutoModeRunning(currentProject.id, true);
logger.debug(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
}, [currentProject, setAutoModeRunning, maxConcurrency]);
@@ -348,6 +396,7 @@ export function useAutoMode() {
return;
}
setAutoModeSessionForProjectPath(currentProject.path, false);
setAutoModeRunning(currentProject.id, false);
// NOTE: We intentionally do NOT clear running tasks here.
// Stopping auto mode only turns off the toggle to prevent new features

View File

@@ -23,6 +23,7 @@ import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
import { isElectron } from '@/lib/electron';
import { getItem, removeItem } from '@/lib/storage';
import { useAppStore } from '@/store/app-store';
import type { GlobalSettings } from '@automaker/types';
const logger = createLogger('SettingsMigration');
@@ -123,7 +124,63 @@ export function useSettingsMigration(): MigrationState {
// If settings files already exist, no migration needed
if (!status.needsMigration) {
logger.info('Settings files exist, no migration needed');
logger.info('Settings files exist - hydrating UI store from server');
// IMPORTANT: the server settings file is now the source of truth.
// If localStorage/Zustand get out of sync (e.g. cleared localStorage),
// the UI can show stale values even though the server will execute with
// the file-based settings. Hydrate the store from the server on startup.
try {
const global = await api.settings.getGlobal();
if (global.success && global.settings) {
const serverSettings = global.settings as unknown as GlobalSettings;
const current = useAppStore.getState();
useAppStore.setState({
theme: serverSettings.theme as unknown as import('@/store/app-store').ThemeMode,
sidebarOpen: serverSettings.sidebarOpen,
chatHistoryOpen: serverSettings.chatHistoryOpen,
kanbanCardDetailLevel: serverSettings.kanbanCardDetailLevel,
maxConcurrency: serverSettings.maxConcurrency,
defaultSkipTests: serverSettings.defaultSkipTests,
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
useWorktrees: serverSettings.useWorktrees,
showProfilesOnly: serverSettings.showProfilesOnly,
defaultPlanningMode: serverSettings.defaultPlanningMode,
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
defaultAIProfileId: serverSettings.defaultAIProfileId,
muteDoneSound: serverSettings.muteDoneSound,
enhancementModel: serverSettings.enhancementModel,
validationModel: serverSettings.validationModel,
phaseModels: serverSettings.phaseModels,
enabledCursorModels: serverSettings.enabledCursorModels,
cursorDefaultModel: serverSettings.cursorDefaultModel,
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
keyboardShortcuts: {
...current.keyboardShortcuts,
...(serverSettings.keyboardShortcuts as unknown as Partial<
typeof current.keyboardShortcuts
>),
},
aiProfiles: serverSettings.aiProfiles,
mcpServers: serverSettings.mcpServers,
promptCustomization: serverSettings.promptCustomization ?? {},
projects: serverSettings.projects,
trashedProjects: serverSettings.trashedProjects,
projectHistory: serverSettings.projectHistory,
projectHistoryIndex: serverSettings.projectHistoryIndex,
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
});
logger.info('Hydrated UI settings from server settings file');
} else {
logger.warn('Failed to load global settings from server:', global);
}
} catch (error) {
logger.error('Failed to hydrate UI settings from server:', error);
}
setState({ checked: true, migrated: false, error: null });
return;
}
@@ -201,14 +258,28 @@ export function useSettingsMigration(): MigrationState {
export async function syncSettingsToServer(): Promise<boolean> {
try {
const api = getHttpApiClient();
const automakerStorage = getItem('automaker-storage');
if (!automakerStorage) {
return false;
// IMPORTANT:
// Prefer the live Zustand state over localStorage to avoid race conditions
// (Zustand persistence writes can lag behind `set(...)`, which would cause us
// to sync stale values to the server).
//
// localStorage remains as a fallback for cases where the store isn't ready.
let state: Record<string, unknown> | null = null;
try {
state = useAppStore.getState() as unknown as Record<string, unknown>;
} catch {
// Ignore and fall back to localStorage
}
const parsed = JSON.parse(automakerStorage);
const state = parsed.state || parsed;
if (!state) {
const automakerStorage = getItem('automaker-storage');
if (!automakerStorage) {
return false;
}
const parsed = JSON.parse(automakerStorage) as Record<string, unknown>;
state = (parsed.state as Record<string, unknown> | undefined) || parsed;
}
// Extract settings to sync
const updates = {
@@ -219,6 +290,7 @@ export async function syncSettingsToServer(): Promise<boolean> {
maxConcurrency: state.maxConcurrency,
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
defaultPlanningMode: state.defaultPlanningMode,
@@ -229,8 +301,6 @@ export async function syncSettingsToServer(): Promise<boolean> {
validationModel: state.validationModel,
phaseModels: state.phaseModels,
autoLoadClaudeMd: state.autoLoadClaudeMd,
enableSandboxMode: state.enableSandboxMode,
skipSandboxWarning: state.skipSandboxWarning,
keyboardShortcuts: state.keyboardShortcuts,
aiProfiles: state.aiProfiles,
mcpServers: state.mcpServers,

View File

@@ -379,32 +379,6 @@ export const verifySession = async (): Promise<boolean> => {
}
};
/**
* Check if the server is running in a containerized (sandbox) environment.
* This endpoint is unauthenticated so it can be checked before login.
*/
export const checkSandboxEnvironment = async (): Promise<{
isContainerized: boolean;
error?: string;
}> => {
try {
const response = await fetch(`${getServerUrl()}/api/health/environment`, {
method: 'GET',
});
if (!response.ok) {
logger.warn('Failed to check sandbox environment');
return { isContainerized: false, error: 'Failed to check environment' };
}
const data = await response.json();
return { isContainerized: data.isContainerized ?? false };
} catch (error) {
logger.error('Sandbox environment check failed:', error);
return { isContainerized: false, error: 'Network error' };
}
};
type EventType =
| 'agent:stream'
| 'auto-mode:event'

View File

@@ -16,28 +16,19 @@ import {
initApiKey,
isElectronMode,
verifySession,
checkSandboxEnvironment,
getServerUrlSync,
checkExternalServerMode,
isExternalServerMode,
} from '@/lib/http-api-client';
import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen';
import { LoadingState } from '@/components/ui/loading-state';
const logger = createLogger('RootLayout');
function RootLayoutContent() {
const location = useLocation();
const {
setIpcConnected,
currentProject,
getEffectiveTheme,
skipSandboxWarning,
setSkipSandboxWarning,
} = useAppStore();
const { setIpcConnected, currentProject, getEffectiveTheme } = useAppStore();
const { setupComplete } = useSetupStore();
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);
@@ -52,12 +43,6 @@ function RootLayoutContent() {
const isSetupRoute = location.pathname === '/setup';
const isLoginRoute = location.pathname === '/login';
// Sandbox environment check state
type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed';
// Always start from pending on a fresh page load so the user sees the prompt
// each time the app is launched/refreshed (unless running in a container).
const [sandboxStatus, setSandboxStatus] = useState<SandboxStatus>('pending');
// Hidden streamer panel - opens with "\" key
const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => {
const activeElement = document.activeElement;
@@ -104,73 +89,6 @@ function RootLayoutContent() {
setIsMounted(true);
}, []);
// Check sandbox environment on mount
useEffect(() => {
// Skip if already decided
if (sandboxStatus !== 'pending') {
return;
}
const checkSandbox = async () => {
try {
const result = await checkSandboxEnvironment();
if (result.isContainerized) {
// Running in a container, no warning needed
setSandboxStatus('containerized');
} else if (skipSandboxWarning) {
// User opted to skip the warning, auto-confirm
setSandboxStatus('confirmed');
} else {
// Not containerized, show warning dialog
setSandboxStatus('needs-confirmation');
}
} catch (error) {
logger.error('Failed to check environment:', error);
// On error, assume not containerized and show warning
if (skipSandboxWarning) {
setSandboxStatus('confirmed');
} else {
setSandboxStatus('needs-confirmation');
}
}
};
checkSandbox();
}, [sandboxStatus, skipSandboxWarning]);
// Handle sandbox risk confirmation
const handleSandboxConfirm = useCallback(
(skipInFuture: boolean) => {
if (skipInFuture) {
setSkipSandboxWarning(true);
}
setSandboxStatus('confirmed');
},
[setSkipSandboxWarning]
);
// Handle sandbox risk denial
const handleSandboxDeny = useCallback(async () => {
if (isElectron()) {
// In Electron mode, quit the application
// Use window.electronAPI directly since getElectronAPI() returns the HTTP client
try {
const electronAPI = window.electronAPI;
if (electronAPI?.quit) {
await electronAPI.quit();
} else {
logger.error('quit() not available on electronAPI');
}
} catch (error) {
logger.error('Failed to quit app:', error);
}
} else {
// In web mode, show rejection screen
setSandboxStatus('denied');
}
}, []);
// Ref to prevent concurrent auth checks from running
const authCheckRunning = useRef(false);
@@ -330,31 +248,11 @@ function RootLayoutContent() {
}
}, [deferredTheme]);
// Show rejection screen if user denied sandbox risk (web mode only)
if (sandboxStatus === 'denied' && !isElectron()) {
return <SandboxRejectionScreen />;
}
// Show loading while checking sandbox environment
if (sandboxStatus === 'pending') {
return (
<main className="flex h-screen items-center justify-center" data-testid="app-container">
<LoadingState message="Checking environment..." />
</main>
);
}
// Show login page (full screen, no sidebar)
if (isLoginRoute) {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<Outlet />
{/* Show sandbox dialog on top of login page if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
</main>
);
}
@@ -386,12 +284,6 @@ function RootLayoutContent() {
return (
<main className="h-screen overflow-hidden" data-testid="app-container">
<Outlet />
{/* Show sandbox dialog on top of setup page if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
</main>
);
}
@@ -420,13 +312,6 @@ function RootLayoutContent() {
}`}
/>
<Toaster richColors position="bottom-right" />
{/* Show sandbox dialog if needed */}
<SandboxRiskDialog
open={sandboxStatus === 'needs-confirmation'}
onConfirm={handleSandboxConfirm}
onDeny={handleSandboxDeny}
/>
</main>
);
}

View File

@@ -1,9 +1,11 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { Project, TrashedProject } from '@/lib/electron';
import { createLogger } from '@automaker/utils/logger';
import type {
Feature as BaseFeature,
FeatureImagePath,
FeatureTextFilePath,
ModelAlias,
PlanningMode,
AIProfile,
@@ -19,8 +21,10 @@ import type {
} from '@automaker/types';
import { getAllCursorModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types';
const logger = createLogger('AppStore');
// Re-export types for convenience
export type { ThemeMode, ModelAlias };
export type { ModelAlias };
export type ViewMode =
| 'welcome'
@@ -460,6 +464,7 @@ export interface AppState {
// Feature Default Settings
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)
// Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
@@ -506,8 +511,6 @@ export interface AppState {
// Claude Agent SDK Settings
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems)
skipSandboxWarning: boolean; // Skip the sandbox environment warning dialog on startup
// MCP Servers
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
@@ -749,6 +752,7 @@ export interface AppActions {
// Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
@@ -804,8 +808,6 @@ export interface AppActions {
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
setSkipSandboxWarning: (skip: boolean) => Promise<void>;
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
@@ -1006,6 +1008,7 @@ const initialState: AppState = {
boardViewMode: 'kanban', // Default to kanban view
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)
useWorktrees: false, // Default to disabled (worktree feature is experimental)
currentWorktreeByProject: {},
worktreesByProject: {},
@@ -1019,8 +1022,6 @@ const initialState: AppState = {
enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default
cursorDefaultModel: 'auto', // Default to auto selection
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
enableSandboxMode: false, // Default to disabled (can be enabled for additional security)
skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog)
mcpServers: [], // No MCP servers configured by default
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
aiProfiles: DEFAULT_AI_PROFILES,
@@ -1574,6 +1575,12 @@ export const useAppStore = create<AppState & AppActions>()(
// Feature Default Settings actions
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }),
setSkipVerificationInAutoMode: async (enabled) => {
set({ skipVerificationInAutoMode: enabled });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
@@ -1703,22 +1710,15 @@ export const useAppStore = create<AppState & AppActions>()(
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: async (enabled) => {
const previous = get().autoLoadClaudeMd;
set({ autoLoadClaudeMd: enabled });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setEnableSandboxMode: async (enabled) => {
set({ enableSandboxMode: enabled });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
},
setSkipSandboxWarning: async (skip) => {
set({ skipSandboxWarning: skip });
// Sync to server settings file
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
await syncSettingsToServer();
const ok = await syncSettingsToServer();
if (!ok) {
logger.error('Failed to sync autoLoadClaudeMd setting to server - reverting');
set({ autoLoadClaudeMd: previous });
}
},
// Prompt Customization actions
setPromptCustomization: async (customization) => {
@@ -2688,8 +2688,9 @@ export const useAppStore = create<AppState & AppActions>()(
const current = get().terminalState;
if (current.tabs.length === 0) {
// Nothing to save, clear any existing layout
const { [projectPath]: _, ...rest } = get().terminalLayoutByProject;
set({ terminalLayoutByProject: rest });
const next = { ...get().terminalLayoutByProject };
delete next[projectPath];
set({ terminalLayoutByProject: next });
return;
}
@@ -2745,8 +2746,9 @@ export const useAppStore = create<AppState & AppActions>()(
},
clearPersistedTerminalLayout: (projectPath) => {
const { [projectPath]: _, ...rest } = get().terminalLayoutByProject;
set({ terminalLayoutByProject: rest });
const next = { ...get().terminalLayoutByProject };
delete next[projectPath];
set({ terminalLayoutByProject: next });
},
// Spec Creation actions
@@ -2995,6 +2997,7 @@ export const useAppStore = create<AppState & AppActions>()(
// Auto-mode should always default to OFF on app refresh
defaultSkipTests: state.defaultSkipTests,
enableDependencyBlocking: state.enableDependencyBlocking,
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
useWorktrees: state.useWorktrees,
currentWorktreeByProject: state.currentWorktreeByProject,
worktreesByProject: state.worktreesByProject,
@@ -3007,8 +3010,6 @@ export const useAppStore = create<AppState & AppActions>()(
enabledCursorModels: state.enabledCursorModels,
cursorDefaultModel: state.cursorDefaultModel,
autoLoadClaudeMd: state.autoLoadClaudeMd,
enableSandboxMode: state.enableSandboxMode,
skipSandboxWarning: state.skipSandboxWarning,
// MCP settings
mcpServers: state.mcpServers,
// Prompt customization

View File

@@ -12,5 +12,6 @@ export {
getAncestors,
formatAncestorContextForPrompt,
type DependencyResolutionResult,
type DependencySatisfactionOptions,
type AncestorContext,
} from './resolver.js';

View File

@@ -174,21 +174,40 @@ function detectCycles(features: Feature[], featureMap: Map<string, Feature>): st
return cycles;
}
export interface DependencySatisfactionOptions {
/** If true, only require dependencies to not be 'running' (ignore verification requirement) */
skipVerification?: boolean;
}
/**
* Checks if a feature's dependencies are satisfied (all complete or verified)
*
* @param feature - Feature to check
* @param allFeatures - All features in the project
* @param options - Optional configuration for dependency checking
* @returns true if all dependencies are satisfied, false otherwise
*/
export function areDependenciesSatisfied(feature: Feature, allFeatures: Feature[]): boolean {
export function areDependenciesSatisfied(
feature: Feature,
allFeatures: Feature[],
options?: DependencySatisfactionOptions
): boolean {
if (!feature.dependencies || feature.dependencies.length === 0) {
return true; // No dependencies = always ready
}
const skipVerification = options?.skipVerification ?? false;
return feature.dependencies.every((depId: string) => {
const dep = allFeatures.find((f) => f.id === depId);
return dep && (dep.status === 'completed' || dep.status === 'verified');
if (!dep) return false;
if (skipVerification) {
// When skipping verification, only block if dependency is currently running
return dep.status !== 'running';
}
// Default: require 'completed' or 'verified'
return dep.status === 'completed' || dep.status === 'verified';
});
}

View File

@@ -77,7 +77,6 @@ export interface ExecuteOptions {
conversationHistory?: ConversationMessage[]; // Previous messages for context
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
settingSources?: Array<'user' | 'project' | 'local'>; // Sources for CLAUDE.md loading
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration
/**
* If true, the provider should run in read-only mode (no file modifications).
* For Cursor CLI, this omits the --force flag, making it suggest-only.

View File

@@ -406,6 +406,8 @@ export interface GlobalSettings {
defaultSkipTests: boolean;
/** Default: enable dependency blocking */
enableDependencyBlocking: boolean;
/** Skip verification requirement in auto-mode (treat 'completed' same as 'verified') */
skipVerificationInAutoMode: boolean;
/** Default: use git worktrees for feature branches */
useWorktrees: boolean;
/** Default: only show AI profiles (hide other settings) */
@@ -474,10 +476,6 @@ export interface GlobalSettings {
// Claude Agent SDK Settings
/** Auto-load CLAUDE.md files using SDK's settingSources option */
autoLoadClaudeMd?: boolean;
/** Enable sandbox mode for bash commands (default: false, enable for additional security) */
enableSandboxMode?: boolean;
/** Skip showing the sandbox risk warning dialog */
skipSandboxWarning?: boolean;
// MCP Server Configuration
/** List of configured MCP servers for agent use */
@@ -650,6 +648,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
maxConcurrency: 3,
defaultSkipTests: true,
enableDependencyBlocking: true,
skipVerificationInAutoMode: false,
useWorktrees: false,
showProfilesOnly: false,
defaultPlanningMode: 'skip',
@@ -672,8 +671,6 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
worktreePanelCollapsed: false,
lastSelectedSessionByProject: {},
autoLoadClaudeMd: false,
enableSandboxMode: false,
skipSandboxWarning: false,
mcpServers: [],
};