Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .claude/commands/create-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,25 @@ Write this JSON file:
- The UI polls this file to detect completion and show the Continue button
- If the user asks for additional changes after you've written this, you may update it again when the new changes are complete

## 4. Register the Project (REQUIRED)

**After writing the status file**, register the project so it appears in the AutoCoder UI.

Run this command using Bash:

```bash
python /home/john/autocoder/register_project.py "<project_name>" "$ARGUMENTS"
```

Where `<project_name>` is derived from the project path (the last directory component, e.g., `my-app` from `~/projects/my-app`).

**Example:**
```bash
python /home/john/autocoder/register_project.py "my-app" "/home/john/projects/my-app"
```

**Note:** If the project is already registered, this will succeed silently. This ensures projects created via `/create-spec` appear in the UI dropdown.

Comment on lines +521 to +539
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Hardcoded user path will break for other users.

The absolute path /home/john/autocoder/register_project.py is user-specific and will fail for anyone with a different username or installation directory. Use a relative path or derive the path dynamically.

Suggested fix
 Run this command using Bash:

 ```bash
-python /home/john/autocoder/register_project.py "<project_name>" "$ARGUMENTS"
+python "$(dirname "$(which start_ui.py)")/register_project.py" "<project_name>" "$ARGUMENTS"

Where <project_name> is derived from the project path (the last directory component, e.g., my-app from ~/projects/my-app).

Example:

-python /home/john/autocoder/register_project.py "my-app" "/home/john/projects/my-app"
+python register_project.py "my-app" "/path/to/projects/my-app"

</details>

Alternatively, if the script is installed to the system PATH or the autocoder directory is known via an environment variable (e.g., `$AUTOCODER_HOME`), reference that instead.

<details>
<summary>🤖 Prompt for AI Agents</summary>

In @.claude/commands/create-spec.md around lines 521 - 539, The hardcoded path
to register_project.py will break for other users; update the example/command to
derive the script location dynamically instead of using
/home/john/autocoder/register_project.py — e.g., locate the installed Autocoder
scripts via which start_ui.py and use its dirname to build the path to
register_project.py, or reference an environment variable like $AUTOCODER_HOME
or a relative path so the command works across installations.


</details>

<!-- fingerprinting:phantom:poseidon:ocelot -->

<!-- This is an auto-generated comment by CodeRabbit -->

---

# AFTER FILE GENERATION: NEXT STEPS
Expand Down
58 changes: 47 additions & 11 deletions agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import io
import re
import sys
import time
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
Expand All @@ -22,8 +23,32 @@
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")


def safe_print(*args, **kwargs) -> None:
"""
Print with retry logic to handle EAGAIN errors.

When stdout is a pipe (subprocess), the buffer can fill up causing
BlockingIOError (errno 11). This function retries with backoff.
"""
max_retries = 5
for attempt in range(max_retries):
try:
print(*args, **kwargs)
return
except BlockingIOError:
if attempt < max_retries - 1:
time.sleep(0.1 * (attempt + 1)) # Backoff: 0.1s, 0.2s, 0.3s...
else:
# Last resort: try without flush
kwargs.pop('flush', None)
try:
print(*args, **kwargs)
except Exception:
pass # Give up silently

from client import create_client
from progress import has_features, print_progress_summary, print_session_header
from progress import all_features_complete, has_features, print_progress_summary, print_session_header
from prompts import (
copy_spec_to_project,
get_coding_prompt,
Expand Down Expand Up @@ -53,7 +78,7 @@ async def run_agent_session(
- "continue" if agent should continue working
- "error" if an error occurred
"""
print("Sending prompt to Claude Agent SDK...\n")
safe_print("Sending prompt to Claude Agent SDK...\n")

try:
# Send the query
Expand All @@ -71,15 +96,15 @@ async def run_agent_session(

if block_type == "TextBlock" and hasattr(block, "text"):
response_text += block.text
print(block.text, end="", flush=True)
safe_print(block.text, end="", flush=True)
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
print(f"\n[Tool: {block.name}]", flush=True)
safe_print(f"\n[Tool: {block.name}]", flush=True)
if hasattr(block, "input"):
input_str = str(block.input)
if len(input_str) > 200:
print(f" Input: {input_str[:200]}...", flush=True)
safe_print(f" Input: {input_str[:200]}...", flush=True)
else:
print(f" Input: {input_str}", flush=True)
safe_print(f" Input: {input_str}", flush=True)

# Handle UserMessage (tool results)
elif msg_type == "UserMessage" and hasattr(msg, "content"):
Expand All @@ -92,20 +117,20 @@ async def run_agent_session(

# Check if command was blocked by security hook
if "blocked" in str(result_content).lower():
print(f" [BLOCKED] {result_content}", flush=True)
safe_print(f" [BLOCKED] {result_content}", flush=True)
elif is_error:
# Show errors (truncated)
error_str = str(result_content)[:500]
print(f" [Error] {error_str}", flush=True)
safe_print(f" [Error] {error_str}", flush=True)
else:
# Tool succeeded - just show brief confirmation
print(" [Done]", flush=True)
safe_print(" [Done]", flush=True)

print("\n" + "-" * 70 + "\n")
safe_print("\n" + "-" * 70 + "\n")
return "continue", response_text

except Exception as e:
print(f"Error during agent session: {e}")
safe_print(f"Error during agent session: {e}")
return "error", str(e)


Expand Down Expand Up @@ -174,6 +199,17 @@ async def run_autonomous_agent(
print("To continue, run the script again without --max-iterations")
break

# Check if all features are complete (skip on first run - initializer hasn't created features yet)
if not is_first_run and all_features_complete(project_dir):
print("\n" + "=" * 70)
print(" ALL FEATURES COMPLETE!")
print("=" * 70)
print("\nAll features have been implemented and are passing.")
print("The agent will now stop to save API credits.")
print("\nTo add more features, use the UI or add them to the database,")
print("then restart the agent.")
break

# Print session header
print_session_header(iteration, is_first_run)

Expand Down
15 changes: 11 additions & 4 deletions client.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,17 @@ def create_client(project_dir: Path, model: str, yolo_mode: bool = False):
}
if not yolo_mode:
# Include Playwright MCP server for browser automation (standard mode only)
# Headless mode is configurable via PLAYWRIGHT_HEADLESS environment variable
playwright_args = ["@playwright/mcp@latest", "--viewport-size", "1280x720"]
if get_playwright_headless():
playwright_args.append("--headless")
# Uses Playwright's bundled Chromium in headless mode for remote/server environments
chromium_path = os.path.expanduser("~/.cache/ms-playwright/chromium-1200/chrome-linux64/chrome")
playwright_args = [
"@playwright/mcp@latest",
"--viewport-size", "1280x720",
"--headless", # Always headless for server environments
"--no-sandbox", # Required for some Linux environments
]
# Use Playwright's Chromium if available (works on headless servers)
if os.path.exists(chromium_path):
playwright_args.extend(["--executable-path", chromium_path])
mcp_servers["playwright"] = {
"command": "npx",
"args": playwright_args,
Expand Down
110 changes: 107 additions & 3 deletions mcp_server/feature_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ def feature_create_bulk(
Features are assigned sequential priorities based on their order.
All features start with passes=false.

Duplicate detection: Features with the same name as existing features
are skipped to prevent duplicates.

This is typically used by the initializer agent to set up the initial
feature list from the app specification.

Expand All @@ -384,26 +387,43 @@ def feature_create_bulk(
- steps (list[str]): Implementation/test steps

Returns:
JSON with: created (int) - number of features created
JSON with: created (int), skipped (int), skipped_names (list)
"""
session = get_session()
try:
# Use lock to prevent race condition in priority assignment
with _priority_lock:
# Get existing feature names for duplicate detection
existing_names = set(
name[0] for name in session.query(Feature.name).all()
)

# Get the starting priority
max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
start_priority = (max_priority_result[0] + 1) if max_priority_result else 1

created_count = 0
skipped_count = 0
skipped_names = []

for i, feature_data in enumerate(features):
# Validate required fields
if not all(key in feature_data for key in ["category", "name", "description", "steps"]):
return json.dumps({
"error": f"Feature at index {i} missing required fields (category, name, description, steps)"
})

# Skip duplicates
if feature_data["name"] in existing_names:
skipped_count += 1
skipped_names.append(feature_data["name"])
continue

# Add to existing names to catch duplicates within this batch
existing_names.add(feature_data["name"])

db_feature = Feature(
priority=start_priority + i,
priority=start_priority + created_count,
category=feature_data["category"],
name=feature_data["name"],
description=feature_data["description"],
Expand All @@ -415,7 +435,91 @@ def feature_create_bulk(

session.commit()

return json.dumps({"created": created_count}, indent=2)
result = {"created": created_count, "skipped": skipped_count}
if skipped_names:
result["skipped_names"] = skipped_names[:10] # Limit to first 10
if len(skipped_names) > 10:
result["skipped_names"].append(f"... and {len(skipped_names) - 10} more")

return json.dumps(result, indent=2)
except Exception as e:
session.rollback()
return json.dumps({"error": str(e)})
finally:
session.close()


@mcp.tool()
def feature_db_repair() -> str:
"""Repair the feature database by removing duplicates and compacting IDs.

This tool performs the following repairs:
1. Removes duplicate features (keeping the one with lowest ID)
2. Compacts IDs to be sequential (1, 2, 3, ...) with no gaps
3. Resets priorities to match the new sequential IDs

Use this if the database has inconsistencies like duplicate IDs or gaps.

Returns:
JSON with: duplicates_removed (int), ids_compacted (bool),
old_max_id (int), new_max_id (int), total_features (int)
"""
session = get_session()
try:
from sqlalchemy import text

# Step 1: Find and remove duplicates (keep lowest ID for each name)
duplicates_query = """
SELECT id FROM features
WHERE id NOT IN (
SELECT MIN(id) FROM features GROUP BY name
)
"""
result = session.execute(text(duplicates_query))
duplicate_ids = [row[0] for row in result.fetchall()]
duplicates_removed = len(duplicate_ids)

if duplicate_ids:
session.execute(
text(f"DELETE FROM features WHERE id IN ({','.join(map(str, duplicate_ids))})")
)
session.commit()

# Step 2: Get current state
all_features = session.query(Feature).order_by(Feature.priority.asc(), Feature.id.asc()).all()
old_max_id = max(f.id for f in all_features) if all_features else 0
total_features = len(all_features)

# Step 3: Check if compaction is needed
expected_ids = set(range(1, total_features + 1))
actual_ids = set(f.id for f in all_features)
needs_compaction = expected_ids != actual_ids

new_max_id = old_max_id
if needs_compaction and all_features:
# Create a mapping from old ID to new ID
# We need to use raw SQL to avoid SQLAlchemy's identity map issues

# First, shift all IDs to negative to avoid conflicts
session.execute(text("UPDATE features SET id = -id"))
session.commit()

# Then assign new sequential IDs
for new_id, feature in enumerate(all_features, start=1):
session.execute(
text(f"UPDATE features SET id = {new_id}, priority = {new_id} WHERE id = {-feature.id}")
)
session.commit()

new_max_id = total_features

return json.dumps({
"duplicates_removed": duplicates_removed,
"ids_compacted": needs_compaction,
"old_max_id": old_max_id,
"new_max_id": new_max_id,
"total_features": total_features
}, indent=2)
except Exception as e:
session.rollback()
return json.dumps({"error": str(e)})
Expand Down
15 changes: 15 additions & 0 deletions progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@
PROGRESS_CACHE_FILE = ".progress_cache"


def all_features_complete(project_dir: Path) -> bool:
"""
Check if all features in the project are complete (passing).

Returns True if:
- There are features AND all of them are passing

Returns False if:
- No features exist, OR
- There are pending/failing features
"""
passing, in_progress, total = count_passing_tests(project_dir)
return total > 0 and passing == total


def has_features(project_dir: Path) -> bool:
"""
Check if the project has features in the database.
Expand Down
65 changes: 65 additions & 0 deletions register_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""
Register Project Script
========================

Simple CLI script to register a project in the autocoder registry.
Called by the /create-spec command after generating spec files.

Usage:
python register_project.py <project_name> <project_path>

Example:
python register_project.py my-app ~/projects/my-app
"""

import sys
from pathlib import Path

# Add parent directory to path so we can import registry
sys.path.insert(0, str(Path(__file__).parent))

from registry import register_project, get_project_path, RegistryError


def main():
if len(sys.argv) != 3:
print("Usage: python register_project.py <project_name> <project_path>", file=sys.stderr)
sys.exit(1)

name = sys.argv[1]
path = Path(sys.argv[2]).expanduser().resolve()

# Check if already registered
existing_path = get_project_path(name)
if existing_path:
if existing_path.resolve() == path:
print(f"Project '{name}' is already registered at {path}")
sys.exit(0)
else:
print(f"Project '{name}' is already registered at a different path: {existing_path}", file=sys.stderr)
sys.exit(1)

# Validate path exists
if not path.exists():
print(f"Error: Path does not exist: {path}", file=sys.stderr)
sys.exit(1)

if not path.is_dir():
print(f"Error: Path is not a directory: {path}", file=sys.stderr)
sys.exit(1)

# Register the project
try:
register_project(name, path)
print(f"Registered project '{name}' at {path}")
except RegistryError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except ValueError as e:
print(f"Invalid project name: {e}", file=sys.stderr)
sys.exit(1)


if __name__ == "__main__":
main()
Loading