Files
automaker/apps/server/src/lib/sdk-options.ts
gsxdsm 0330c70261 Feature: worktree view customization and stability fixes (#805)
* Changes from feature/worktree-view-customization

* Feature: Git sync, set-tracking, and push divergence handling (#796)

* Add quick-add feature with improved workflows (#802)

* Changes from feature/quick-add

* feat: Clarify system prompt and improve error handling across services. Address PR Feedback

* feat: Improve PR description parsing and refactor event handling

* feat: Add context options to pipeline orchestrator initialization

* fix: Deduplicate React and handle CJS interop for use-sync-external-store

Resolve "Cannot read properties of null (reading 'useState')" errors by
deduplicating React/react-dom and ensuring use-sync-external-store is
bundled together with React to prevent CJS packages from resolving to
different React instances.

* Changes from feature/worktree-view-customization

* refactor: Remove unused worktree swap and highlight props

* refactor: Consolidate feature completion logic and improve thinking level defaults

* feat: Increase max turn limit to 10000

- Update DEFAULT_MAX_TURNS from 1000 to 10000 in settings-helpers.ts and agent-executor.ts
- Update MAX_ALLOWED_TURNS from 2000 to 10000 in settings-helpers.ts
- Update UI clamping logic from 2000 to 10000 in app-store.ts
- Update fallback values from 1000 to 10000 in use-settings-sync.ts
- Update default value from 1000 to 10000 in DEFAULT_GLOBAL_SETTINGS
- Update documentation to reflect new range: 1-10000

Allows agents to perform up to 10000 turns for complex feature execution.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* feat: Add model resolution, improve session handling, and enhance UI stability

* refactor: Remove unused sync and tracking branch props from worktree components

* feat: Add PR number update functionality to worktrees. Address pr feedback

* feat: Optimize Gemini CLI startup and add tool result tracking

* refactor: Improve error handling and simplify worktree task cleanup

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-02-23 20:31:25 -08:00

624 lines
20 KiB
TypeScript

/**
* SDK Options Factory - Centralized configuration for Claude Agent SDK
*
* Provides presets for common use cases:
* - Spec generation: Long-running analysis with read-only tools
* - Feature generation: Quick JSON generation from specs
* - Feature building: Autonomous feature implementation with full tool access
* - Suggestions: Analysis with read-only tools
* - Chat: Full tool access for interactive coding
*
* Uses model-resolver for consistent model handling across the application.
*
* SECURITY: All factory functions validate the working directory (cwd) against
* ALLOWED_ROOT_DIRECTORY before returning options. This provides a centralized
* security check that applies to ALL AI model invocations, regardless of provider.
*/
import type { Options } from '@anthropic-ai/claude-agent-sdk';
import path from 'path';
import { resolveModelString } from '@automaker/model-resolver';
import { createLogger } from '@automaker/utils';
const logger = createLogger('SdkOptions');
import {
DEFAULT_MODELS,
CLAUDE_MODEL_MAP,
type McpServerConfig,
type ThinkingLevel,
getThinkingTokenBudget,
} from '@automaker/types';
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
/**
* Result of sandbox compatibility check
*/
export interface SandboxCompatibilityResult {
/** Whether sandbox mode can be enabled for this path */
enabled: boolean;
/** Optional message explaining why sandbox is disabled */
message?: string;
}
/**
* Check if a working directory is compatible with sandbox mode.
* Some paths (like cloud storage mounts) may not work with sandboxed execution.
*
* @param cwd - The working directory to check
* @param sandboxRequested - Whether sandbox mode was requested by settings
* @returns Object indicating if sandbox can be enabled and why not if disabled
*/
export function checkSandboxCompatibility(
cwd: string,
sandboxRequested: boolean
): SandboxCompatibilityResult {
if (!sandboxRequested) {
return { enabled: false };
}
const resolvedCwd = path.resolve(cwd);
// Check for cloud storage paths that may not be compatible with sandbox
const cloudStoragePatterns = [
// macOS mounted volumes
/^\/Volumes\/GoogleDrive/i,
/^\/Volumes\/Dropbox/i,
/^\/Volumes\/OneDrive/i,
/^\/Volumes\/iCloud/i,
// macOS home directory
/^\/Users\/[^/]+\/Google Drive/i,
/^\/Users\/[^/]+\/Dropbox/i,
/^\/Users\/[^/]+\/OneDrive/i,
/^\/Users\/[^/]+\/Library\/Mobile Documents/i, // iCloud
// Linux home directory
/^\/home\/[^/]+\/Google Drive/i,
/^\/home\/[^/]+\/Dropbox/i,
/^\/home\/[^/]+\/OneDrive/i,
// Windows
/^C:\\Users\\[^\\]+\\Google Drive/i,
/^C:\\Users\\[^\\]+\\Dropbox/i,
/^C:\\Users\\[^\\]+\\OneDrive/i,
];
for (const pattern of cloudStoragePatterns) {
if (pattern.test(resolvedCwd)) {
return {
enabled: false,
message: `Sandbox disabled: Cloud storage path detected (${resolvedCwd}). Sandbox mode may not work correctly with cloud-synced directories.`,
};
}
}
return { enabled: true };
}
/**
* Validate that a working directory is allowed by ALLOWED_ROOT_DIRECTORY.
* This is the centralized security check for ALL AI model invocations.
*
* @param cwd - The working directory to validate
* @throws PathNotAllowedError if the directory is not within ALLOWED_ROOT_DIRECTORY
*
* This function is called by all create*Options() factory functions to ensure
* that AI models can only operate within allowed directories. This applies to:
* - All current models (Claude, future models)
* - All invocation types (chat, auto-mode, spec generation, etc.)
*/
export function validateWorkingDirectory(cwd: string): void {
const resolvedCwd = path.resolve(cwd);
if (!isPathAllowed(resolvedCwd)) {
const allowedRoot = getAllowedRootDirectory();
throw new PathNotAllowedError(
`Working directory "${cwd}" (resolved: ${resolvedCwd}) is not allowed. ` +
(allowedRoot
? `Must be within ALLOWED_ROOT_DIRECTORY: ${allowedRoot}`
: 'ALLOWED_ROOT_DIRECTORY is configured but path is not within allowed directories.')
);
}
}
/**
* Tool presets for different use cases
*/
export const TOOL_PRESETS = {
/** Read-only tools for analysis */
readOnly: ['Read', 'Glob', 'Grep'] as const,
/** Tools for spec generation that needs to read the codebase */
specGeneration: ['Read', 'Glob', 'Grep'] as const,
/** Full tool access for feature implementation */
fullAccess: [
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
'Task',
'Skill',
] as const,
/** Tools for chat/interactive mode */
chat: [
'Read',
'Write',
'Edit',
'MultiEdit',
'Glob',
'Grep',
'LS',
'Bash',
'WebSearch',
'WebFetch',
'TodoWrite',
'Task',
'Skill',
] as const,
} as const;
/**
* Max turns presets for different use cases
*/
export const MAX_TURNS = {
/** Quick operations that shouldn't need many iterations */
quick: 50,
/** Standard operations */
standard: 100,
/** Long-running operations like full spec generation */
extended: 250,
/** Very long operations that may require extensive exploration */
maximum: 1000,
} as const;
/**
* Model presets for different use cases
*
* These can be overridden via environment variables:
* - AUTOMAKER_MODEL_SPEC: Model for spec generation
* - AUTOMAKER_MODEL_FEATURES: Model for feature generation
* - AUTOMAKER_MODEL_SUGGESTIONS: Model for suggestions
* - AUTOMAKER_MODEL_CHAT: Model for chat
* - AUTOMAKER_MODEL_DEFAULT: Fallback model for all operations
*/
export function getModelForUseCase(
useCase: 'spec' | 'features' | 'suggestions' | 'chat' | 'auto' | 'default',
explicitModel?: string
): string {
// Explicit model takes precedence
if (explicitModel) {
return resolveModelString(explicitModel);
}
// Check environment variable override for this use case
const envVarMap: Record<string, string | undefined> = {
spec: process.env.AUTOMAKER_MODEL_SPEC,
features: process.env.AUTOMAKER_MODEL_FEATURES,
suggestions: process.env.AUTOMAKER_MODEL_SUGGESTIONS,
chat: process.env.AUTOMAKER_MODEL_CHAT,
auto: process.env.AUTOMAKER_MODEL_AUTO,
default: process.env.AUTOMAKER_MODEL_DEFAULT,
};
const envModel = envVarMap[useCase] || envVarMap.default;
if (envModel) {
return resolveModelString(envModel);
}
const defaultModels: Record<string, string> = {
spec: CLAUDE_MODEL_MAP['haiku'], // used to generate app specs
features: CLAUDE_MODEL_MAP['haiku'], // used to generate features from app specs
suggestions: CLAUDE_MODEL_MAP['haiku'], // used for suggestions
chat: CLAUDE_MODEL_MAP['haiku'], // used for chat
auto: CLAUDE_MODEL_MAP['opus'], // used to implement kanban cards
default: CLAUDE_MODEL_MAP['opus'],
};
return resolveModelString(defaultModels[useCase] || DEFAULT_MODELS.claude);
}
/**
* Base options that apply to all SDK calls
* AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
*/
function getBaseOptions(): Partial<Options> {
return {
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
};
}
/**
* MCP options result
*/
interface McpOptions {
/** Options to spread for MCP servers */
mcpServerOptions: Partial<Options>;
}
/**
* Build MCP-related options based on configuration.
*
* @param config - The SDK options config
* @returns Object with MCP server settings to spread into final options
*/
function buildMcpOptions(config: CreateSdkOptionsConfig): McpOptions {
return {
// Include MCP servers if configured
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
};
}
/**
* Build thinking options for SDK configuration.
* Converts ThinkingLevel to maxThinkingTokens for the Claude SDK.
* For adaptive thinking (Opus 4.6), omits maxThinkingTokens to let the model
* decide its own reasoning depth.
*
* @param thinkingLevel - The thinking level to convert
* @returns Object with maxThinkingTokens if thinking is enabled with a budget
*/
function buildThinkingOptions(thinkingLevel?: ThinkingLevel): Partial<Options> {
if (!thinkingLevel || thinkingLevel === 'none') {
return {};
}
// Adaptive thinking (Opus 4.6): don't set maxThinkingTokens
// The model will use adaptive thinking by default
if (thinkingLevel === 'adaptive') {
logger.debug(
`buildThinkingOptions: thinkingLevel="adaptive" -> no maxThinkingTokens (model decides)`
);
return {};
}
// Manual budget-based thinking for Haiku/Sonnet
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
logger.debug(
`buildThinkingOptions: thinkingLevel="${thinkingLevel}" -> maxThinkingTokens=${maxThinkingTokens}`
);
return maxThinkingTokens ? { maxThinkingTokens } : {};
}
/**
* Build system prompt and settingSources based on two independent settings:
* - useClaudeCodeSystemPrompt: controls whether to use the 'claude_code' preset as the base prompt
* - autoLoadClaudeMd: controls whether to add settingSources for SDK to load CLAUDE.md files
*
* These combine independently (4 possible states):
* 1. Both ON: preset + settingSources (full Claude Code experience)
* 2. useClaudeCodeSystemPrompt ON, autoLoadClaudeMd OFF: preset only (no CLAUDE.md auto-loading)
* 3. useClaudeCodeSystemPrompt OFF, autoLoadClaudeMd ON: plain string + settingSources
* 4. Both OFF: plain string only
*
* @param config - The SDK options config
* @returns Object with systemPrompt and settingSources for SDK options
*/
function buildClaudeMdOptions(config: CreateSdkOptionsConfig): {
systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>;
} {
const result: {
systemPrompt?: string | SystemPromptConfig;
settingSources?: Array<'user' | 'project' | 'local'>;
} = {};
// Determine system prompt format based on useClaudeCodeSystemPrompt
if (config.useClaudeCodeSystemPrompt) {
// Use Claude Code's built-in system prompt as the base
const presetConfig: SystemPromptConfig = {
type: 'preset',
preset: 'claude_code',
};
// If there's a custom system prompt, append it to the preset
if (config.systemPrompt) {
presetConfig.append = config.systemPrompt;
}
result.systemPrompt = presetConfig;
} else {
// Standard mode - just pass through the system prompt as-is
if (config.systemPrompt) {
result.systemPrompt = config.systemPrompt;
}
}
// Determine settingSources based on autoLoadClaudeMd
if (config.autoLoadClaudeMd) {
// Load both user (~/.claude/CLAUDE.md) and project (.claude/CLAUDE.md) settings
result.settingSources = ['user', 'project'];
}
return result;
}
/**
* System prompt configuration for SDK options
* The 'claude_code' preset provides the system prompt only — it does NOT auto-load
* CLAUDE.md files. CLAUDE.md auto-loading is controlled independently by
* settingSources (set via autoLoadClaudeMd). These two settings are orthogonal.
*/
export interface SystemPromptConfig {
/** Use preset mode to select the base system prompt */
type: 'preset';
/** The preset to use - 'claude_code' uses the Claude Code system prompt */
preset: 'claude_code';
/** Optional additional prompt to append to the preset */
append?: string;
}
/**
* Options configuration for creating SDK options
*/
export interface CreateSdkOptionsConfig {
/** Working directory for the agent */
cwd: string;
/** Optional explicit model override */
model?: string;
/** Optional session model (used as fallback if explicit model not provided) */
sessionModel?: string;
/** Optional system prompt */
systemPrompt?: string;
/** Optional abort controller for cancellation */
abortController?: AbortController;
/** Optional output format for structured outputs */
outputFormat?: {
type: 'json_schema';
schema: Record<string, unknown>;
};
/** Enable auto-loading of CLAUDE.md files via SDK's settingSources */
autoLoadClaudeMd?: boolean;
/** Use Claude Code's built-in system prompt (claude_code preset) as the base prompt */
useClaudeCodeSystemPrompt?: boolean;
/** MCP servers to make available to the agent */
mcpServers?: Record<string, McpServerConfig>;
/** Extended thinking level for Claude models */
thinkingLevel?: ThinkingLevel;
/** Optional user-configured max turns override (from settings).
* When provided, overrides the preset MAX_TURNS for the use case.
* Range: 1-2000. */
maxTurns?: number;
}
// Re-export MCP types from @automaker/types for convenience
export type {
McpServerConfig,
McpStdioServerConfig,
McpSSEServerConfig,
McpHttpServerConfig,
} from '@automaker/types';
/**
* Create SDK options for spec generation
*
* Configuration:
* - Uses read-only tools for codebase analysis
* - Extended turns for thorough exploration
* - Opus model by default (can be overridden)
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createSpecGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
return {
...getBaseOptions(),
// Override permissionMode - spec generation only needs read-only tools
// Using "acceptEdits" can cause Claude to write files to unexpected locations
// See: https://github.com/AutoMaker-Org/automaker/issues/149
permissionMode: 'default',
model: getModelForUseCase('spec', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.specGeneration],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }),
};
}
/**
* Create SDK options for feature generation from specs
*
* Configuration:
* - Uses read-only tools (just needs to read the spec)
* - Quick turns since it's mostly JSON generation
* - Sonnet model by default for speed
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createFeatureGenerationOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
return {
...getBaseOptions(),
// Override permissionMode - feature generation only needs read-only tools
permissionMode: 'default',
model: getModelForUseCase('features', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.quick,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
};
}
/**
* Create SDK options for generating suggestions
*
* Configuration:
* - Uses read-only tools for analysis
* - Standard turns to allow thorough codebase exploration and structured output generation
* - Opus model by default for thorough analysis
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createSuggestionsOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
return {
...getBaseOptions(),
model: getModelForUseCase('suggestions', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.extended,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.readOnly],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...(config.outputFormat && { outputFormat: config.outputFormat }),
};
}
/**
* Create SDK options for chat/interactive mode
*
* Configuration:
* - Full tool access for code modification
* - Standard turns for interactive sessions
* - Model priority: explicit model > session model > chat default
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createChatOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Model priority: explicit model > session model > chat default
const effectiveModel = config.model || config.sessionModel;
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
return {
...getBaseOptions(),
model: getModelForUseCase('chat', effectiveModel),
maxTurns: config.maxTurns ?? MAX_TURNS.standard,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.chat],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
};
}
/**
* Create SDK options for autonomous feature building/implementation
*
* Configuration:
* - Full tool access for code modification and implementation
* - Extended turns for thorough feature implementation
* - Uses default model (can be overridden)
* - When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
return {
...getBaseOptions(),
model: getModelForUseCase('auto', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: [...TOOL_PRESETS.fullAccess],
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
};
}
/**
* Create custom SDK options with explicit configuration
*
* Use this when the preset options don't fit your use case.
* When autoLoadClaudeMd is true, uses preset mode and settingSources for CLAUDE.md loading
*/
export function createCustomOptions(
config: CreateSdkOptionsConfig & {
maxTurns?: number;
allowedTools?: readonly string[];
}
): Options {
// Validate working directory before creating options
validateWorkingDirectory(config.cwd);
// Build CLAUDE.md auto-loading options if enabled
const claudeMdOptions = buildClaudeMdOptions(config);
// Build MCP-related options
const mcpOptions = buildMcpOptions(config);
// Build thinking options
const thinkingOptions = buildThinkingOptions(config.thinkingLevel);
// For custom options: use explicit allowedTools if provided, otherwise default to readOnly
const effectiveAllowedTools = config.allowedTools
? [...config.allowedTools]
: [...TOOL_PRESETS.readOnly];
return {
...getBaseOptions(),
model: getModelForUseCase('default', config.model),
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
cwd: config.cwd,
allowedTools: effectiveAllowedTools,
...claudeMdOptions,
...thinkingOptions,
...(config.abortController && { abortController: config.abortController }),
...mcpOptions.mcpServerOptions,
};
}