Files
Aperant/apps/backend/core/client.py
T
StillKnotKnown e310d56f3d fix: increase Claude SDK JSON buffer size to 10MB (#815)
Prevents spec creation failures when tool results exceed the default 1MB buffer limit during discovery/research phases.

Related: #813

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
2026-01-08 10:26:05 +01:00

818 lines
32 KiB
Python

"""
Claude SDK Client Configuration
===============================
Functions for creating and configuring the Claude Agent SDK client.
All AI interactions should use `create_client()` to ensure consistent OAuth authentication
and proper tool/MCP configuration. For simple message calls without full agent sessions,
use `create_simple_client()` from `core.simple_client`.
The client factory now uses AGENT_CONFIGS from agents/tools_pkg/models.py as the
single source of truth for phase-aware tool and MCP server configuration.
"""
import copy
import json
import logging
import os
import platform
import threading
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# =============================================================================
# Project Index Cache
# =============================================================================
# Caches project index and capabilities to avoid reloading on every create_client() call.
# This significantly reduces the time to create new agent sessions.
_PROJECT_INDEX_CACHE: dict[str, tuple[dict[str, Any], dict[str, bool], float]] = {}
_CACHE_TTL_SECONDS = 300 # 5 minute TTL
_CACHE_LOCK = threading.Lock() # Protects _PROJECT_INDEX_CACHE access
def _get_cached_project_data(
project_dir: Path,
) -> tuple[dict[str, Any], dict[str, bool]]:
"""
Get project index and capabilities with caching.
Args:
project_dir: Path to the project directory
Returns:
Tuple of (project_index, project_capabilities)
"""
key = str(project_dir.resolve())
now = time.time()
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
# Check cache with lock
with _CACHE_LOCK:
if key in _PROJECT_INDEX_CACHE:
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
cache_age = now - cached_time
if cache_age < _CACHE_TTL_SECONDS:
if debug:
print(
f"[ClientCache] Cache HIT for project index (age: {cache_age:.1f}s / TTL: {_CACHE_TTL_SECONDS}s)"
)
logger.debug(f"Using cached project index for {project_dir}")
# Return deep copies to prevent callers from corrupting the cache
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
elif debug:
print(
f"[ClientCache] Cache EXPIRED for project index (age: {cache_age:.1f}s > TTL: {_CACHE_TTL_SECONDS}s)"
)
# Cache miss or expired - load fresh data (outside lock to avoid blocking)
load_start = time.time()
logger.debug(f"Loading project index for {project_dir}")
project_index = load_project_index(project_dir)
project_capabilities = detect_project_capabilities(project_index)
if debug:
load_duration = (time.time() - load_start) * 1000
print(
f"[ClientCache] Cache MISS - loaded project index in {load_duration:.1f}ms"
)
# Store in cache with lock - use double-checked locking pattern
# Re-check if another thread populated the cache while we were loading
with _CACHE_LOCK:
if key in _PROJECT_INDEX_CACHE:
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
cache_age = time.time() - cached_time
if cache_age < _CACHE_TTL_SECONDS:
# Another thread already cached valid data while we were loading
if debug:
print(
"[ClientCache] Cache was populated by another thread, using cached data"
)
# Return deep copies to prevent callers from corrupting the cache
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
# Either no cache entry or it's expired - store our fresh data
_PROJECT_INDEX_CACHE[key] = (project_index, project_capabilities, time.time())
# Return the freshly loaded data (no need to copy since it's not from cache)
return project_index, project_capabilities
def invalidate_project_cache(project_dir: Path | None = None) -> None:
"""
Invalidate the project index cache.
Args:
project_dir: Specific project to invalidate, or None to clear all
"""
with _CACHE_LOCK:
if project_dir is None:
_PROJECT_INDEX_CACHE.clear()
logger.debug("Cleared all project index cache entries")
else:
key = str(project_dir.resolve())
if key in _PROJECT_INDEX_CACHE:
del _PROJECT_INDEX_CACHE[key]
logger.debug(f"Invalidated project index cache for {project_dir}")
from agents.tools_pkg import (
CONTEXT7_TOOLS,
ELECTRON_TOOLS,
GRAPHITI_MCP_TOOLS,
LINEAR_TOOLS,
PUPPETEER_TOOLS,
create_auto_claude_mcp_server,
get_allowed_tools,
get_required_mcp_servers,
is_tools_available,
)
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from claude_agent_sdk.types import HookMatcher
from core.auth import get_sdk_env_vars, require_auth_token
from linear_updater import is_linear_enabled
from prompts_pkg.project_context import detect_project_capabilities, load_project_index
from security import bash_security_hook
def _validate_custom_mcp_server(server: dict) -> bool:
"""
Validate a custom MCP server configuration for security.
Ensures only expected fields with valid types are present.
Rejects configurations that could lead to command injection.
Args:
server: Dict representing a custom MCP server configuration
Returns:
True if valid, False otherwise
"""
if not isinstance(server, dict):
return False
# Required fields
required_fields = {"id", "name", "type"}
if not all(field in server for field in required_fields):
logger.warning(
f"Custom MCP server missing required fields: {required_fields - server.keys()}"
)
return False
# Validate field types
if not isinstance(server.get("id"), str) or not server["id"]:
return False
if not isinstance(server.get("name"), str) or not server["name"]:
return False
# FIX: Changed from ('command', 'url') to ('command', 'http') to match actual usage
if server.get("type") not in ("command", "http"):
logger.warning(f"Invalid MCP server type: {server.get('type')}")
return False
# Allowlist of safe executable commands for MCP servers
# Only allow known package managers and interpreters - NO shell commands
SAFE_COMMANDS = {
"npx",
"npm",
"node",
"python",
"python3",
"uv",
"uvx",
}
# Blocklist of dangerous shell commands that should never be allowed
DANGEROUS_COMMANDS = {
"bash",
"sh",
"cmd",
"powershell",
"pwsh", # PowerShell Core
"/bin/bash",
"/bin/sh",
"/bin/zsh",
"/usr/bin/bash",
"/usr/bin/sh",
"zsh",
"fish",
}
# Dangerous interpreter flags that allow arbitrary code execution
# Covers Python (-e, -c, -m, -p), Node.js (--eval, --print, loaders), and general
DANGEROUS_FLAGS = {
"--eval",
"-e",
"-c",
"--exec",
"-m", # Python module execution
"-p", # Python eval+print
"--print", # Node.js print
"--input-type=module", # Node.js ES module mode
"--experimental-loader", # Node.js custom loaders
"--require", # Node.js require injection
"-r", # Node.js require shorthand
}
# Type-specific validation
if server["type"] == "command":
if not isinstance(server.get("command"), str) or not server["command"]:
logger.warning("Command-type MCP server missing 'command' field")
return False
# SECURITY FIX: Validate command is in safe list and not in dangerous list
command = server.get("command", "")
# Reject paths - commands must be bare names only (no / or \)
# This prevents path traversal like '/custom/malicious' or './evil'
if "/" in command or "\\" in command:
logger.warning(
f"Rejected command with path in MCP server: {command}. "
f"Commands must be bare names without path separators."
)
return False
if command in DANGEROUS_COMMANDS:
logger.warning(
f"Rejected dangerous command in MCP server: {command}. "
f"Shell commands are not allowed for security reasons."
)
return False
if command not in SAFE_COMMANDS:
logger.warning(
f"Rejected unknown command in MCP server: {command}. "
f"Only allowed commands: {', '.join(sorted(SAFE_COMMANDS))}"
)
return False
# Validate args is a list of strings if present
if "args" in server:
if not isinstance(server["args"], list):
return False
if not all(isinstance(arg, str) for arg in server["args"]):
return False
# Check for dangerous interpreter flags that allow code execution
for arg in server["args"]:
if arg in DANGEROUS_FLAGS:
logger.warning(
f"Rejected dangerous flag '{arg}' in MCP server args. "
f"Interpreter code execution flags are not allowed."
)
return False
elif server["type"] == "http":
if not isinstance(server.get("url"), str) or not server["url"]:
logger.warning("HTTP-type MCP server missing 'url' field")
return False
# Validate headers is a dict of strings if present
if "headers" in server:
if not isinstance(server["headers"], dict):
return False
if not all(
isinstance(k, str) and isinstance(v, str)
for k, v in server["headers"].items()
):
return False
# Optional description must be string if present
if "description" in server and not isinstance(server.get("description"), str):
return False
# Reject any unexpected fields that could be exploited
allowed_fields = {
"id",
"name",
"type",
"command",
"args",
"url",
"headers",
"description",
}
unexpected_fields = set(server.keys()) - allowed_fields
if unexpected_fields:
logger.warning(f"Custom MCP server has unexpected fields: {unexpected_fields}")
return False
return True
def load_project_mcp_config(project_dir: Path) -> dict:
"""
Load MCP configuration from project's .auto-claude/.env file.
Returns a dict of MCP-related env vars:
- CONTEXT7_ENABLED (default: true)
- LINEAR_MCP_ENABLED (default: true)
- ELECTRON_MCP_ENABLED (default: false)
- PUPPETEER_MCP_ENABLED (default: false)
- AGENT_MCP_<agent>_ADD (per-agent MCP additions)
- AGENT_MCP_<agent>_REMOVE (per-agent MCP removals)
- CUSTOM_MCP_SERVERS (JSON array of custom server configs)
Args:
project_dir: Path to the project directory
Returns:
Dict of MCP configuration values (string values, except CUSTOM_MCP_SERVERS which is parsed JSON)
"""
env_path = project_dir / ".auto-claude" / ".env"
if not env_path.exists():
return {}
config = {}
mcp_keys = {
"CONTEXT7_ENABLED",
"LINEAR_MCP_ENABLED",
"ELECTRON_MCP_ENABLED",
"PUPPETEER_MCP_ENABLED",
}
try:
with open(env_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip("\"'")
# Include global MCP toggles
if key in mcp_keys:
config[key] = value
# Include per-agent MCP overrides (AGENT_MCP_<agent>_ADD/REMOVE)
elif key.startswith("AGENT_MCP_"):
config[key] = value
# Include custom MCP servers (parse JSON with schema validation)
elif key == "CUSTOM_MCP_SERVERS":
try:
parsed = json.loads(value)
if not isinstance(parsed, list):
logger.warning(
"CUSTOM_MCP_SERVERS must be a JSON array"
)
config["CUSTOM_MCP_SERVERS"] = []
else:
# Validate each server and filter out invalid ones
valid_servers = []
for i, server in enumerate(parsed):
if _validate_custom_mcp_server(server):
valid_servers.append(server)
else:
logger.warning(
f"Skipping invalid custom MCP server at index {i}"
)
config["CUSTOM_MCP_SERVERS"] = valid_servers
except json.JSONDecodeError:
logger.warning(
f"Failed to parse CUSTOM_MCP_SERVERS JSON: {value}"
)
config["CUSTOM_MCP_SERVERS"] = []
except Exception as e:
logger.debug(f"Failed to load project MCP config from {env_path}: {e}")
return config
def is_graphiti_mcp_enabled() -> bool:
"""
Check if Graphiti MCP server integration is enabled.
Requires GRAPHITI_MCP_URL to be set (e.g., http://localhost:8000/mcp/)
This is separate from GRAPHITI_ENABLED which controls the Python library integration.
"""
return bool(os.environ.get("GRAPHITI_MCP_URL"))
def get_graphiti_mcp_url() -> str:
"""Get the Graphiti MCP server URL."""
return os.environ.get("GRAPHITI_MCP_URL", "http://localhost:8000/mcp/")
def is_electron_mcp_enabled() -> bool:
"""
Check if Electron MCP server integration is enabled.
Requires ELECTRON_MCP_ENABLED to be set to 'true'.
When enabled, QA agents can use Puppeteer MCP tools to connect to Electron apps
via Chrome DevTools Protocol on the configured debug port.
"""
return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true"
def get_electron_debug_port() -> int:
"""Get the Electron remote debugging port (default: 9222)."""
return int(os.environ.get("ELECTRON_DEBUG_PORT", "9222"))
def should_use_claude_md() -> bool:
"""Check if CLAUDE.md instructions should be included in system prompt."""
return os.environ.get("USE_CLAUDE_MD", "").lower() == "true"
def load_claude_md(project_dir: Path) -> str | None:
"""
Load CLAUDE.md content from project root if it exists.
Args:
project_dir: Root directory of the project
Returns:
Content of CLAUDE.md if found, None otherwise
"""
claude_md_path = project_dir / "CLAUDE.md"
if claude_md_path.exists():
try:
return claude_md_path.read_text(encoding="utf-8")
except Exception:
return None
return None
def create_client(
project_dir: Path,
spec_dir: Path,
model: str,
agent_type: str = "coder",
max_thinking_tokens: int | None = None,
output_format: dict | None = None,
agents: dict | None = None,
) -> ClaudeSDKClient:
"""
Create a Claude Agent SDK client with multi-layered security.
Uses AGENT_CONFIGS for phase-aware tool and MCP server configuration.
Only starts MCP servers that the agent actually needs, reducing context
window bloat and startup latency.
Args:
project_dir: Root directory for the project (working directory)
spec_dir: Directory containing the spec (for settings file)
model: Claude model to use
agent_type: Agent type identifier from AGENT_CONFIGS
(e.g., 'coder', 'planner', 'qa_reviewer', 'spec_gatherer')
max_thinking_tokens: Token budget for extended thinking (None = disabled)
- ultrathink: 16000 (spec creation)
- high: 10000 (QA review)
- medium: 5000 (planning, validation)
- None: disabled (coding)
output_format: Optional structured output format for validated JSON responses.
Use {"type": "json_schema", "schema": Model.model_json_schema()}
See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
agents: Optional dict of subagent definitions for SDK parallel execution.
Format: {"agent-name": {"description": "...", "prompt": "...",
"tools": [...], "model": "inherit"}}
See: https://platform.claude.com/docs/en/agent-sdk/subagents
Returns:
Configured ClaudeSDKClient
Raises:
ValueError: If agent_type is not found in AGENT_CONFIGS
Security layers (defense in depth):
1. Sandbox - OS-level bash command isolation prevents filesystem escape
2. Permissions - File operations restricted to project_dir only
3. Security hooks - Bash commands validated against an allowlist
(see security.py for ALLOWED_COMMANDS)
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
"""
oauth_token = require_auth_token()
# Ensure SDK can access it via its expected env var
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
sdk_env = get_sdk_env_vars()
# Debug: Log git-bash path detection on Windows
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
elif platform.system() == "Windows":
logger.warning("Git Bash path not detected on Windows!")
# Check if Linear integration is enabled
linear_enabled = is_linear_enabled()
linear_api_key = os.environ.get("LINEAR_API_KEY", "")
# Check if custom auto-claude tools are available
auto_claude_tools_enabled = is_tools_available()
# Load project capabilities for dynamic MCP tool selection
# This enables context-aware tool injection based on project type
# Uses caching to avoid reloading on every create_client() call
project_index, project_capabilities = _get_cached_project_data(project_dir)
# Load per-project MCP configuration from .auto-claude/.env
mcp_config = load_project_mcp_config(project_dir)
# Get allowed tools using phase-aware configuration
# This respects AGENT_CONFIGS and only includes tools the agent needs
# Also respects per-project MCP configuration
allowed_tools_list = get_allowed_tools(
agent_type,
project_capabilities,
linear_enabled,
mcp_config,
)
# Get required MCP servers for this agent type
# This is the key optimization - only start servers the agent needs
# Now also respects per-project MCP configuration
required_servers = get_required_mcp_servers(
agent_type,
project_capabilities,
linear_enabled,
mcp_config,
)
# Check if Graphiti MCP is enabled (already filtered by get_required_mcp_servers)
graphiti_mcp_enabled = "graphiti" in required_servers
# Determine browser tools for permissions (already in allowed_tools_list)
browser_tools_permissions = []
if "electron" in required_servers:
browser_tools_permissions = ELECTRON_TOOLS
elif "puppeteer" in required_servers:
browser_tools_permissions = PUPPETEER_TOOLS
# Create comprehensive security settings
# Note: Using both relative paths ("./**") and absolute paths to handle
# cases where Claude uses absolute paths for file operations
project_path_str = str(project_dir.resolve())
spec_path_str = str(spec_dir.resolve())
# Detect if we're running in a worktree and get the original project directory
# Worktrees are located in either:
# - .auto-claude/worktrees/tasks/{spec-name}/ (new location)
# - .worktrees/{spec-name}/ (legacy location)
# When running in a worktree, we need to allow access to both the worktree
# and the original project's .auto-claude/ directory for spec files
original_project_permissions = []
resolved_project_path = project_dir.resolve()
# Check for worktree paths and extract original project directory
# This handles spec worktrees, PR review worktrees, and legacy worktrees
# Note: Windows paths are normalized to forward slashes before comparison
worktree_markers = [
"/.auto-claude/worktrees/tasks/", # Spec/task worktrees
"/.auto-claude/github/pr/worktrees/", # PR review worktrees
"/.worktrees/", # Legacy worktree location
]
project_path_posix = str(resolved_project_path).replace("\\", "/")
for marker in worktree_markers:
if marker in project_path_posix:
# Extract the original project directory (parent of worktree location)
# Use rsplit to get the rightmost occurrence (handles nested projects)
original_project_str = project_path_posix.rsplit(marker, 1)[0]
original_project_dir = Path(original_project_str)
# Grant permissions for relevant directories in the original project
permission_ops = ["Read", "Write", "Edit", "Glob", "Grep"]
dirs_to_permit = [
original_project_dir / ".auto-claude",
original_project_dir / ".worktrees", # Legacy support
]
for dir_path in dirs_to_permit:
if dir_path.exists():
path_str = str(dir_path.resolve())
original_project_permissions.extend(
[f"{op}({path_str}/**)" for op in permission_ops]
)
break
security_settings = {
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
"permissions": {
"defaultMode": "acceptEdits", # Auto-approve edits within allowed directories
"allow": [
# Allow all file operations within the project directory
# Include both relative (./**) and absolute paths for compatibility
"Read(./**)",
"Write(./**)",
"Edit(./**)",
"Glob(./**)",
"Grep(./**)",
# Also allow absolute paths (Claude sometimes uses full paths)
f"Read({project_path_str}/**)",
f"Write({project_path_str}/**)",
f"Edit({project_path_str}/**)",
f"Glob({project_path_str}/**)",
f"Grep({project_path_str}/**)",
# Allow spec directory explicitly (needed when spec is in worktree)
f"Read({spec_path_str}/**)",
f"Write({spec_path_str}/**)",
f"Edit({spec_path_str}/**)",
# Allow original project's .auto-claude/ and .worktrees/ directories
# when running in a worktree (fixes issue #385 - permission errors)
*original_project_permissions,
# Bash permission granted here, but actual commands are validated
# by the bash_security_hook (see security.py for allowed commands)
"Bash(*)",
# Allow web tools for documentation and research
"WebFetch(*)",
"WebSearch(*)",
# Allow MCP tools based on required servers
# Format: tool_name(*) allows all arguments
*(
[f"{tool}(*)" for tool in CONTEXT7_TOOLS]
if "context7" in required_servers
else []
),
*(
[f"{tool}(*)" for tool in LINEAR_TOOLS]
if "linear" in required_servers
else []
),
*(
[f"{tool}(*)" for tool in GRAPHITI_MCP_TOOLS]
if graphiti_mcp_enabled
else []
),
*[f"{tool}(*)" for tool in browser_tools_permissions],
],
},
}
# Write settings to a file in the project directory
settings_file = project_dir / ".claude_settings.json"
with open(settings_file, "w") as f:
json.dump(security_settings, f, indent=2)
print(f"Security settings: {settings_file}")
print(" - Sandbox enabled (OS-level bash isolation)")
print(f" - Filesystem restricted to: {project_dir.resolve()}")
if original_project_permissions:
print(" - Worktree permissions: granted for original project directories")
print(" - Bash commands restricted to allowlist")
if max_thinking_tokens:
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
else:
print(" - Extended thinking: disabled")
# Build list of MCP servers for display based on required_servers
mcp_servers_list = []
if "context7" in required_servers:
mcp_servers_list.append("context7 (documentation)")
if "electron" in required_servers:
mcp_servers_list.append(
f"electron (desktop automation, port {get_electron_debug_port()})"
)
if "puppeteer" in required_servers:
mcp_servers_list.append("puppeteer (browser automation)")
if "linear" in required_servers:
mcp_servers_list.append("linear (project management)")
if graphiti_mcp_enabled:
mcp_servers_list.append("graphiti-memory (knowledge graph)")
if "auto-claude" in required_servers and auto_claude_tools_enabled:
mcp_servers_list.append(f"auto-claude ({agent_type} tools)")
if mcp_servers_list:
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
else:
print(" - MCP servers: none (minimal configuration)")
# Show detected project capabilities for QA agents
if agent_type in ("qa_reviewer", "qa_fixer") and any(project_capabilities.values()):
caps = [
k.replace("is_", "").replace("has_", "")
for k, v in project_capabilities.items()
if v
]
print(f" - Project capabilities: {', '.join(caps)}")
print()
# Configure MCP servers - ONLY start servers that are required
# This is the key optimization to reduce context bloat and startup latency
mcp_servers = {}
if "context7" in required_servers:
mcp_servers["context7"] = {
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
}
if "electron" in required_servers:
# Electron MCP for desktop apps
# Electron app must be started with --remote-debugging-port=<port>
mcp_servers["electron"] = {
"command": "npm",
"args": ["exec", "electron-mcp-server"],
}
if "puppeteer" in required_servers:
# Puppeteer for web frontends (not Electron)
mcp_servers["puppeteer"] = {
"command": "npx",
"args": ["puppeteer-mcp-server"],
}
if "linear" in required_servers:
mcp_servers["linear"] = {
"type": "http",
"url": "https://mcp.linear.app/mcp",
"headers": {"Authorization": f"Bearer {linear_api_key}"},
}
# Graphiti MCP server for knowledge graph memory
if graphiti_mcp_enabled:
mcp_servers["graphiti-memory"] = {
"type": "http",
"url": get_graphiti_mcp_url(),
}
# Add custom auto-claude MCP server if required and available
if "auto-claude" in required_servers and auto_claude_tools_enabled:
auto_claude_mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir)
if auto_claude_mcp_server:
mcp_servers["auto-claude"] = auto_claude_mcp_server
# Add custom MCP servers from project config
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
for custom in custom_servers:
server_id = custom.get("id")
if not server_id:
continue
# Only include if agent has it in their effective server list
if server_id not in required_servers:
continue
server_type = custom.get("type", "command")
if server_type == "command":
mcp_servers[server_id] = {
"command": custom.get("command", "npx"),
"args": custom.get("args", []),
}
elif server_type == "http":
server_config = {
"type": "http",
"url": custom.get("url", ""),
}
if custom.get("headers"):
server_config["headers"] = custom["headers"]
mcp_servers[server_id] = server_config
# Build system prompt
base_prompt = (
f"You are an expert full-stack developer building production-quality software. "
f"Your working directory is: {project_dir.resolve()}\n"
f"Your filesystem access is RESTRICTED to this directory only. "
f"Use relative paths (starting with ./) for all file operations. "
f"Never use absolute paths or try to access files outside your working directory.\n\n"
f"You follow existing code patterns, write clean maintainable code, and verify "
f"your work through thorough testing. You communicate progress through Git commits "
f"and build-progress.txt updates."
)
# Include CLAUDE.md if enabled and present
if should_use_claude_md():
claude_md_content = load_claude_md(project_dir)
if claude_md_content:
base_prompt = f"{base_prompt}\n\n# Project Instructions (from CLAUDE.md)\n\n{claude_md_content}"
print(" - CLAUDE.md: included in system prompt")
else:
print(" - CLAUDE.md: not found in project root")
else:
print(" - CLAUDE.md: disabled by project settings")
print()
# Build options dict, conditionally including output_format
options_kwargs = {
"model": model,
"system_prompt": base_prompt,
"allowed_tools": allowed_tools_list,
"mcp_servers": mcp_servers,
"hooks": {
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[bash_security_hook]),
],
},
"max_turns": 1000,
"cwd": str(project_dir.resolve()),
"settings": str(settings_file.resolve()),
"env": sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
"max_thinking_tokens": max_thinking_tokens, # Extended thinking budget
"max_buffer_size": 10
* 1024
* 1024, # 10MB buffer (default: 1MB) - fixes large tool results
# Enable file checkpointing to track file read/write state across tool calls
# This prevents "File has not been read yet" errors in recovery sessions
"enable_file_checkpointing": True,
}
# Add structured output format if specified
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
if output_format:
options_kwargs["output_format"] = output_format
# Add subagent definitions if specified
# See: https://platform.claude.com/docs/en/agent-sdk/subagents
if agents:
options_kwargs["agents"] = agents
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))