Merge branch 'master' into master

This commit is contained in:
syphonetic
2026-02-04 05:50:35 +08:00
committed by GitHub
98 changed files with 7360 additions and 4816 deletions

View File

@@ -25,25 +25,13 @@ from .assistant_database import (
create_conversation,
get_messages,
)
from .chat_constants import API_ENV_VARS, ROOT_DIR
# Load environment variables from .env file if present
load_dotenv()
logger = logging.getLogger(__name__)
# Root directory of the project
ROOT_DIR = Path(__file__).parent.parent.parent
# Environment variables to pass through to Claude CLI for API configuration
API_ENV_VARS = [
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"API_TIMEOUT_MS",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
]
# Read-only feature MCP tools
READONLY_FEATURE_MCP_TOOLS = [
"mcp__features__feature_get_stats",
@@ -76,7 +64,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
"""Generate the system prompt for the assistant with project context."""
# Try to load app_spec.txt for context
app_spec_content = ""
app_spec_path = project_dir / "prompts" / "app_spec.txt"
from autocoder_paths import get_prompts_dir
app_spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
if app_spec_path.exists():
try:
app_spec_content = app_spec_path.read_text(encoding="utf-8")
@@ -90,6 +79,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str:
Your role is to help users understand the codebase, answer questions about features, and manage the project backlog. You can READ files and CREATE/MANAGE features, but you cannot modify source code.
You have MCP tools available for feature management. Use them directly by calling the tool -- do not suggest CLI commands, bash commands, or curl commands to the user. You can create features yourself using the feature_create and feature_create_bulk tools.
## What You CAN Do
**Codebase Analysis (Read-Only):**
@@ -134,17 +125,21 @@ If the user asks you to modify code, explain that you're a project assistant and
## Creating Features
When a user asks to add a feature, gather the following information:
1. **Category**: A grouping like "Authentication", "API", "UI", "Database"
2. **Name**: A concise, descriptive name
3. **Description**: What the feature should do
4. **Steps**: How to verify/implement the feature (as a list)
When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly:
For a **single feature**, call `feature_create` with:
- category: A grouping like "Authentication", "API", "UI", "Database"
- name: A concise, descriptive name
- description: What the feature should do
- steps: List of verification/implementation steps
For **multiple features**, call `feature_create_bulk` with an array of feature objects.
You can ask clarifying questions if the user's request is vague, or make reasonable assumptions for simple requests.
**Example interaction:**
User: "Add a feature for S3 sync"
You: I'll create that feature. Let me add it to the backlog...
You: I'll create that feature now.
[calls feature_create with appropriate parameters]
You: Done! I've added "S3 Sync Integration" to your backlog. It's now visible on the kanban board.
@@ -208,7 +203,7 @@ class AssistantChatSession:
# Create a new conversation if we don't have one
if is_new_conversation:
conv = create_conversation(self.project_dir, self.project_name)
self.conversation_id = conv.id
self.conversation_id = int(conv.id) # type coercion: Column[int] -> int
yield {"type": "conversation_created", "conversation_id": self.conversation_id}
# Build permissions list for assistant access (read + feature management)
@@ -229,7 +224,9 @@ class AssistantChatSession:
"allow": permissions_list,
},
}
settings_file = self.project_dir / ".claude_assistant_settings.json"
from autocoder_paths import get_claude_assistant_settings_path
settings_file = get_claude_assistant_settings_path(self.project_dir)
settings_file.parent.mkdir(parents=True, exist_ok=True)
with open(settings_file, "w") as f:
json.dump(security_settings, f, indent=2)
@@ -261,7 +258,11 @@ class AssistantChatSession:
system_cli = shutil.which("claude")
# Build environment overrides for API configuration
sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)}
sdk_env: dict[str, str] = {}
for var in API_ENV_VARS:
value = os.getenv(var)
if value:
sdk_env[var] = value
# Determine model from environment or use default
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
@@ -277,7 +278,7 @@ class AssistantChatSession:
# This avoids Windows command line length limit (~8191 chars)
setting_sources=["project"],
allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS],
mcp_servers=mcp_servers,
mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime
permission_mode="bypassPermissions",
max_turns=100,
cwd=str(self.project_dir.resolve()),
@@ -303,6 +304,8 @@ class AssistantChatSession:
greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"
# Store the greeting in the database
# conversation_id is guaranteed non-None here (set on line 206 above)
assert self.conversation_id is not None
add_message(self.project_dir, self.conversation_id, "assistant", greeting)
yield {"type": "text", "content": greeting}

View File

@@ -7,20 +7,28 @@ Each project has its own assistant.db file in the project directory.
"""
import logging
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, create_engine, func
from sqlalchemy.orm import declarative_base, relationship, sessionmaker
from sqlalchemy.engine import Engine
from sqlalchemy.orm import DeclarativeBase, relationship, sessionmaker
logger = logging.getLogger(__name__)
Base = declarative_base()
class Base(DeclarativeBase):
"""SQLAlchemy 2.0 style declarative base."""
pass
# Engine cache to avoid creating new engines for each request
# Key: project directory path (as posix string), Value: SQLAlchemy engine
_engine_cache: dict[str, object] = {}
_engine_cache: dict[str, Engine] = {}
# Lock for thread-safe access to the engine cache
# Prevents race conditions when multiple threads create engines simultaneously
_cache_lock = threading.Lock()
def _utc_now() -> datetime:
@@ -56,7 +64,8 @@ class ConversationMessage(Base):
def get_db_path(project_dir: Path) -> Path:
"""Get the path to the assistant database for a project."""
return project_dir / "assistant.db"
from autocoder_paths import get_assistant_db_path
return get_assistant_db_path(project_dir)
def get_engine(project_dir: Path):
@@ -64,17 +73,33 @@ def get_engine(project_dir: Path):
Uses a cache to avoid creating new engines for each request, which improves
performance by reusing database connections.
Thread-safe: Uses a lock to prevent race conditions when multiple threads
try to create engines simultaneously for the same project.
"""
cache_key = project_dir.as_posix()
if cache_key not in _engine_cache:
db_path = get_db_path(project_dir)
# Use as_posix() for cross-platform compatibility with SQLite connection strings
db_url = f"sqlite:///{db_path.as_posix()}"
engine = create_engine(db_url, echo=False)
Base.metadata.create_all(engine)
_engine_cache[cache_key] = engine
logger.debug(f"Created new database engine for {cache_key}")
# Double-checked locking for thread safety and performance
if cache_key in _engine_cache:
return _engine_cache[cache_key]
with _cache_lock:
# Check again inside the lock in case another thread created it
if cache_key not in _engine_cache:
db_path = get_db_path(project_dir)
# Use as_posix() for cross-platform compatibility with SQLite connection strings
db_url = f"sqlite:///{db_path.as_posix()}"
engine = create_engine(
db_url,
echo=False,
connect_args={
"check_same_thread": False,
"timeout": 30, # Wait up to 30s for locks
}
)
Base.metadata.create_all(engine)
_engine_cache[cache_key] = engine
logger.debug(f"Created new database engine for {cache_key}")
return _engine_cache[cache_key]

View File

@@ -0,0 +1,57 @@
"""
Chat Session Constants
======================
Shared constants for all chat session types (assistant, spec, expand).
The canonical ``API_ENV_VARS`` list lives in ``env_constants.py`` at the
project root and is re-exported here for convenience so that existing
imports (``from .chat_constants import API_ENV_VARS``) continue to work.
"""
import sys
from pathlib import Path
from typing import AsyncGenerator
# -------------------------------------------------------------------
# Root directory of the autocoder project (repository root).
# Used throughout the server package whenever the repo root is needed.
# -------------------------------------------------------------------
ROOT_DIR = Path(__file__).parent.parent.parent
# Ensure the project root is on sys.path so we can import env_constants
# from the root-level module without requiring a package install.
_root_str = str(ROOT_DIR)
if _root_str not in sys.path:
sys.path.insert(0, _root_str)
# -------------------------------------------------------------------
# Environment variables forwarded to Claude CLI subprocesses.
# Single source of truth lives in env_constants.py at the project root.
# Re-exported here so existing ``from .chat_constants import API_ENV_VARS``
# imports continue to work unchanged.
# -------------------------------------------------------------------
from env_constants import API_ENV_VARS # noqa: E402, F401
async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
"""Yield a single multimodal user message in Claude Agent SDK format.
The Claude Agent SDK's ``query()`` method accepts either a plain string
or an ``AsyncIterable[dict]`` for custom message formats. This helper
wraps a list of content blocks (text and/or images) in the expected
envelope.
Args:
content_blocks: List of content-block dicts, e.g.
``[{"type": "text", "text": "..."}, {"type": "image", ...}]``.
Yields:
A single dict representing the user message.
"""
yield {
"type": "user",
"message": {"role": "user", "content": content_blocks},
"parent_tool_use_id": None,
"session_id": "default",
}

View File

@@ -16,28 +16,19 @@ import threading
import uuid
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator, Optional
from typing import Any, AsyncGenerator, Optional
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from dotenv import load_dotenv
from ..schemas import ImageAttachment
from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
# Load environment variables from .env file if present
load_dotenv()
logger = logging.getLogger(__name__)
# Environment variables to pass through to Claude CLI for API configuration
API_ENV_VARS = [
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"API_TIMEOUT_MS",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
]
# Feature MCP tools needed for expand session
EXPAND_FEATURE_TOOLS = [
"mcp__features__feature_create",
@@ -46,22 +37,6 @@ EXPAND_FEATURE_TOOLS = [
]
async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
"""
Create an async generator that yields a properly formatted multimodal message.
"""
yield {
"type": "user",
"message": {"role": "user", "content": content_blocks},
"parent_tool_use_id": None,
"session_id": "default",
}
# Root directory of the project
ROOT_DIR = Path(__file__).parent.parent.parent
class ExpandChatSession:
"""
Manages a project expansion conversation.
@@ -128,7 +103,8 @@ class ExpandChatSession:
return
# Verify project has existing spec
spec_path = self.project_dir / "prompts" / "app_spec.txt"
from autocoder_paths import get_prompts_dir
spec_path = get_prompts_dir(self.project_dir) / "app_spec.txt"
if not spec_path.exists():
yield {
"type": "error",
@@ -162,10 +138,13 @@ class ExpandChatSession:
"allow": [
"Read(./**)",
"Glob(./**)",
*EXPAND_FEATURE_TOOLS,
],
},
}
settings_file = self.project_dir / f".claude_settings.expand.{uuid.uuid4().hex}.json"
from autocoder_paths import get_expand_settings_path
settings_file = get_expand_settings_path(self.project_dir, uuid.uuid4().hex)
settings_file.parent.mkdir(parents=True, exist_ok=True)
self._settings_file = settings_file
with open(settings_file, "w", encoding="utf-8") as f:
json.dump(security_settings, f, indent=2)
@@ -175,7 +154,12 @@ class ExpandChatSession:
system_prompt = skill_content.replace("$ARGUMENTS", project_path)
# Build environment overrides for API configuration
sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)}
# Filter to only include vars that are actually set (non-None)
sdk_env: dict[str, str] = {}
for var in API_ENV_VARS:
value = os.getenv(var)
if value:
sdk_env[var] = value
# Determine model from environment or use default
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
@@ -203,9 +187,12 @@ class ExpandChatSession:
allowed_tools=[
"Read",
"Glob",
"Grep",
"WebFetch",
"WebSearch",
*EXPAND_FEATURE_TOOLS,
],
mcp_servers=mcp_servers,
mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime
permission_mode="bypassPermissions",
max_turns=100,
cwd=str(self.project_dir.resolve()),
@@ -299,7 +286,7 @@ class ExpandChatSession:
# Build the message content
if attachments and len(attachments) > 0:
content_blocks = []
content_blocks: list[dict[str, Any]] = []
if message:
content_blocks.append({"type": "text", "text": message})
for att in attachments:
@@ -311,7 +298,7 @@ class ExpandChatSession:
"data": att.base64Data,
}
})
await self.client.query(_make_multimodal_message(content_blocks))
await self.client.query(make_multimodal_message(content_blocks))
logger.info(f"Sent multimodal message with {len(attachments)} image(s)")
else:
await self.client.query(message)

View File

@@ -15,7 +15,7 @@ import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Awaitable, Callable, Literal, Set
from typing import Any, Awaitable, Callable, Literal, Set
import psutil
@@ -92,7 +92,8 @@ class AgentProcessManager:
self._callbacks_lock = threading.Lock()
# Lock file to prevent multiple instances (stored in project directory)
self.lock_file = self.project_dir / ".agent.lock"
from autocoder_paths import get_agent_lock_path
self.lock_file = get_agent_lock_path(self.project_dir)
@property
def status(self) -> Literal["stopped", "running", "paused", "crashed"]:
@@ -296,6 +297,8 @@ class AgentProcessManager:
parallel_mode: bool = False,
max_concurrency: int | None = None,
testing_agent_ratio: int = 1,
playwright_headless: bool = True,
batch_size: int = 3,
) -> tuple[bool, str]:
"""
Start the agent as a subprocess.
@@ -306,6 +309,7 @@ class AgentProcessManager:
parallel_mode: DEPRECATED - ignored, always uses unified orchestrator
max_concurrency: Max concurrent coding agents (1-5, default 1)
testing_agent_ratio: Number of regression testing agents (0-3, default 1)
playwright_headless: If True, run browser in headless mode
Returns:
Tuple of (success, message)
@@ -346,18 +350,21 @@ class AgentProcessManager:
# Add testing agent configuration
cmd.extend(["--testing-ratio", str(testing_agent_ratio)])
# Add --batch-size flag for multi-feature batching
cmd.extend(["--batch-size", str(batch_size)])
try:
# Start subprocess with piped stdout/stderr
# Use project_dir as cwd so Claude SDK sandbox allows access to project files
# stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
# CREATE_NO_WINDOW on Windows prevents console window pop-ups
# PYTHONUNBUFFERED ensures output isn't delayed
popen_kwargs = {
popen_kwargs: dict[str, Any] = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.PIPE,
"stderr": subprocess.STDOUT,
"cwd": str(self.project_dir),
"env": {**os.environ, "PYTHONUNBUFFERED": "1"},
"env": {**os.environ, "PYTHONUNBUFFERED": "1", "PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false"},
}
if sys.platform == "win32":
popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
@@ -579,8 +586,18 @@ def cleanup_orphaned_locks() -> int:
if not project_path.exists():
continue
lock_file = project_path / ".agent.lock"
if not lock_file.exists():
# Check both legacy and new locations for lock files
from autocoder_paths import get_autocoder_dir
lock_locations = [
project_path / ".agent.lock",
get_autocoder_dir(project_path) / ".agent.lock",
]
lock_file = None
for candidate in lock_locations:
if candidate.exists():
lock_file = candidate
break
if lock_file is None:
continue
try:

View File

@@ -92,8 +92,9 @@ class SchedulerService:
async def _load_project_schedules(self, project_name: str, project_dir: Path) -> int:
"""Load schedules for a single project. Returns count of schedules loaded."""
from api.database import Schedule, create_database
from autocoder_paths import get_features_db_path
db_path = project_dir / "features.db"
db_path = get_features_db_path(project_dir)
if not db_path.exists():
return 0
@@ -567,8 +568,9 @@ class SchedulerService:
):
"""Check if a project should be started on server startup."""
from api.database import Schedule, ScheduleOverride, create_database
from autocoder_paths import get_features_db_path
db_path = project_dir / "features.db"
db_path = get_features_db_path(project_dir)
if not db_path.exists():
return

View File

@@ -13,49 +13,19 @@ import shutil
import threading
from datetime import datetime
from pathlib import Path
from typing import AsyncGenerator, Optional
from typing import Any, AsyncGenerator, Optional
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from dotenv import load_dotenv
from ..schemas import ImageAttachment
from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
# Load environment variables from .env file if present
load_dotenv()
logger = logging.getLogger(__name__)
# Environment variables to pass through to Claude CLI for API configuration
API_ENV_VARS = [
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"API_TIMEOUT_MS",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
]
async def _make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
"""
Create an async generator that yields a properly formatted multimodal message.
The Claude Agent SDK's query() method accepts either:
- A string (simple text)
- An AsyncIterable[dict] (for custom message formats)
This function wraps content blocks in the expected message format.
"""
yield {
"type": "user",
"message": {"role": "user", "content": content_blocks},
"parent_tool_use_id": None,
"session_id": "default",
}
# Root directory of the project
ROOT_DIR = Path(__file__).parent.parent.parent
class SpecChatSession:
"""
@@ -125,7 +95,8 @@ class SpecChatSession:
# Delete app_spec.txt so Claude can create it fresh
# The SDK requires reading existing files before writing, but app_spec.txt is created new
# Note: We keep initializer_prompt.md so Claude can read and update the template
prompts_dir = self.project_dir / "prompts"
from autocoder_paths import get_prompts_dir
prompts_dir = get_prompts_dir(self.project_dir)
app_spec_path = prompts_dir / "app_spec.txt"
if app_spec_path.exists():
app_spec_path.unlink()
@@ -145,7 +116,9 @@ class SpecChatSession:
],
},
}
settings_file = self.project_dir / ".claude_settings.json"
from autocoder_paths import get_claude_settings_path
settings_file = get_claude_settings_path(self.project_dir)
settings_file.parent.mkdir(parents=True, exist_ok=True)
with open(settings_file, "w") as f:
json.dump(security_settings, f, indent=2)
@@ -167,7 +140,12 @@ class SpecChatSession:
system_cli = shutil.which("claude")
# Build environment overrides for API configuration
sdk_env = {var: os.getenv(var) for var in API_ENV_VARS if os.getenv(var)}
# Filter to only include vars that are actually set (non-None)
sdk_env: dict[str, str] = {}
for var in API_ENV_VARS:
value = os.getenv(var)
if value:
sdk_env[var] = value
# Determine model from environment or use default
# This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
@@ -289,7 +267,7 @@ class SpecChatSession:
# Build the message content
if attachments and len(attachments) > 0:
# Multimodal message: build content blocks array
content_blocks = []
content_blocks: list[dict[str, Any]] = []
# Add text block if there's text
if message:
@@ -308,7 +286,7 @@ class SpecChatSession:
# Send multimodal content to Claude using async generator format
# The SDK's query() accepts AsyncIterable[dict] for custom message formats
await self.client.query(_make_multimodal_message(content_blocks))
await self.client.query(make_multimodal_message(content_blocks))
logger.info(f"Sent multimodal message with {len(attachments)} image(s)")
else:
# Text-only message: use string format
@@ -317,7 +295,7 @@ class SpecChatSession:
current_text = ""
# Track pending writes for BOTH required files
pending_writes = {
pending_writes: dict[str, dict[str, Any] | None] = {
"app_spec": None, # {"tool_id": ..., "path": ...}
"initializer": None, # {"tool_id": ..., "path": ...}
}
@@ -392,7 +370,8 @@ class SpecChatSession:
logger.warning(f"Tool error: {content}")
# Clear any pending writes that failed
for key in pending_writes:
if pending_writes[key] and tool_use_id == pending_writes[key].get("tool_id"):
pending_write = pending_writes[key]
if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
logger.error(f"{key} write failed: {content}")
pending_writes[key] = None
else:

View File

@@ -371,7 +371,7 @@ class TerminalSession:
# Reap zombie if not already reaped
if self._child_pid is not None:
try:
os.waitpid(self._child_pid, os.WNOHANG)
os.waitpid(self._child_pid, os.WNOHANG) # type: ignore[attr-defined] # Unix-only method, guarded by runtime platform selection
except ChildProcessError:
pass
except Exception:
@@ -736,7 +736,7 @@ async def cleanup_all_terminals() -> None:
Called on server shutdown to ensure all PTY processes are terminated.
"""
with _sessions_lock:
all_sessions = []
all_sessions: list[TerminalSession] = []
for project_sessions in _sessions.values():
all_sessions.extend(project_sessions.values())