Skip to content
Merged
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
69 changes: 57 additions & 12 deletions src/autocoder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,31 @@
A powerful autonomous coding system with parallel agents, web UI, and MCP tools.
"""

__version__ = "0.1.0"
from __future__ import annotations

import importlib
from typing import TYPE_CHECKING, Any

# Core system exports
from autocoder.core.orchestrator import Orchestrator, create_orchestrator
from autocoder.core.gatekeeper import Gatekeeper
from autocoder.core.worktree_manager import WorktreeManager
from autocoder.core.knowledge_base import KnowledgeBase, get_knowledge_base
from autocoder.core.model_settings import ModelSettings, ModelPreset, get_full_model_id
from autocoder.core.test_framework_detector import TestFrameworkDetector
from autocoder.core.database import Database, get_database
__version__ = "0.1.0"

# Agent exports
from autocoder.agent.agent import run_autonomous_agent
from autocoder.agent.client import ClaudeSDKClient
_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
# Core system
"Orchestrator": ("autocoder.core.orchestrator", "Orchestrator"),
"create_orchestrator": ("autocoder.core.orchestrator", "create_orchestrator"),
"Gatekeeper": ("autocoder.core.gatekeeper", "Gatekeeper"),
"WorktreeManager": ("autocoder.core.worktree_manager", "WorktreeManager"),
"KnowledgeBase": ("autocoder.core.knowledge_base", "KnowledgeBase"),
"get_knowledge_base": ("autocoder.core.knowledge_base", "get_knowledge_base"),
"ModelSettings": ("autocoder.core.model_settings", "ModelSettings"),
"ModelPreset": ("autocoder.core.model_settings", "ModelPreset"),
"get_full_model_id": ("autocoder.core.model_settings", "get_full_model_id"),
"TestFrameworkDetector": ("autocoder.core.test_framework_detector", "TestFrameworkDetector"),
"Database": ("autocoder.core.database", "Database"),
"get_database": ("autocoder.core.database", "get_database"),
# Agent
"run_autonomous_agent": ("autocoder.agent.agent", "run_autonomous_agent"),
"ClaudeSDKClient": ("autocoder.agent.client", "ClaudeSDKClient"),
}

__all__ = [
# Core system
Expand All @@ -37,3 +48,37 @@
"run_autonomous_agent",
"ClaudeSDKClient",
]

if TYPE_CHECKING:
from autocoder.agent.agent import run_autonomous_agent as run_autonomous_agent
from autocoder.agent.client import ClaudeSDKClient as ClaudeSDKClient
from autocoder.core.database import Database as Database
from autocoder.core.database import get_database as get_database
from autocoder.core.gatekeeper import Gatekeeper as Gatekeeper
from autocoder.core.knowledge_base import KnowledgeBase as KnowledgeBase
from autocoder.core.knowledge_base import get_knowledge_base as get_knowledge_base
from autocoder.core.model_settings import ModelPreset as ModelPreset
from autocoder.core.model_settings import ModelSettings as ModelSettings
from autocoder.core.model_settings import get_full_model_id as get_full_model_id
from autocoder.core.orchestrator import Orchestrator as Orchestrator
from autocoder.core.orchestrator import create_orchestrator as create_orchestrator
from autocoder.core.test_framework_detector import (
TestFrameworkDetector as TestFrameworkDetector,
)
from autocoder.core.worktree_manager import WorktreeManager as WorktreeManager


def __getattr__(name: str) -> Any:
spec = _LAZY_EXPORTS.get(name)
if not spec:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

module_name, attr_name = spec
module = importlib.import_module(module_name)
value = getattr(module, attr_name)
globals()[name] = value # Cache for future access
return value


def __dir__() -> list[str]:
return sorted(list(globals().keys()) + list(_LAZY_EXPORTS.keys()))
58 changes: 45 additions & 13 deletions src/autocoder/agent/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,22 @@
- security: Command validation whitelist
"""

from autocoder.agent.agent import run_autonomous_agent
from autocoder.agent.client import ClaudeSDKClient
from autocoder.agent.prompts import (
scaffold_project_prompts,
has_project_prompts,
get_project_prompts_dir,
)
from autocoder.agent.registry import (
register_project,
get_project_path,
list_registered_projects,
)
from autocoder.agent.security import ALLOWED_COMMANDS
from __future__ import annotations

import importlib
from typing import TYPE_CHECKING, Any

_LAZY_EXPORTS: dict[str, tuple[str, str]] = {
"run_autonomous_agent": ("autocoder.agent.agent", "run_autonomous_agent"),
"ClaudeSDKClient": ("autocoder.agent.client", "ClaudeSDKClient"),
"scaffold_project_prompts": ("autocoder.agent.prompts", "scaffold_project_prompts"),
"has_project_prompts": ("autocoder.agent.prompts", "has_project_prompts"),
"get_project_prompts_dir": ("autocoder.agent.prompts", "get_project_prompts_dir"),
"register_project": ("autocoder.agent.registry", "register_project"),
"get_project_path": ("autocoder.agent.registry", "get_project_path"),
"list_registered_projects": ("autocoder.agent.registry", "list_registered_projects"),
"ALLOWED_COMMANDS": ("autocoder.agent.security", "ALLOWED_COMMANDS"),
}

__all__ = [
"run_autonomous_agent",
Expand All @@ -35,3 +38,32 @@
"list_registered_projects",
"ALLOWED_COMMANDS",
]

if TYPE_CHECKING:
from autocoder.agent.agent import run_autonomous_agent as run_autonomous_agent
from autocoder.agent.client import ClaudeSDKClient as ClaudeSDKClient
from autocoder.agent.prompts import (
scaffold_project_prompts as scaffold_project_prompts,
)
from autocoder.agent.prompts import has_project_prompts as has_project_prompts
from autocoder.agent.prompts import get_project_prompts_dir as get_project_prompts_dir
from autocoder.agent.registry import register_project as register_project
from autocoder.agent.registry import get_project_path as get_project_path
from autocoder.agent.registry import list_registered_projects as list_registered_projects
from autocoder.agent.security import ALLOWED_COMMANDS as ALLOWED_COMMANDS


def __getattr__(name: str) -> Any:
spec = _LAZY_EXPORTS.get(name)
if not spec:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

module_name, attr_name = spec
module = importlib.import_module(module_name)
value = getattr(module, attr_name)
globals()[name] = value # Cache for future access
return value


def __dir__() -> list[str]:
return sorted(list(globals().keys()) + list(_LAZY_EXPORTS.keys()))
79 changes: 23 additions & 56 deletions src/autocoder/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,9 @@
import sys
import os
import re
from datetime import datetime, timedelta
from datetime import datetime
from pathlib import Path
from typing import Optional

from claude_agent_sdk import ClaudeSDKClient

try:
from zoneinfo import ZoneInfo
except Exception: # pragma: no cover - very old Python / missing tzdata
ZoneInfo = None # type: ignore[assignment]
from typing import TYPE_CHECKING, Optional

# Fix Windows console encoding for Unicode characters (emoji, etc.)
# Without this, print() can crash when Claude outputs emoji like ✅
Expand All @@ -34,8 +27,10 @@
except Exception:
pass

if TYPE_CHECKING:
from claude_agent_sdk import ClaudeSDKClient

from ..core.port_config import get_web_port
from .client import create_client
from .progress import print_session_header, print_progress_summary, has_features
from .prompts import (
get_initializer_prompt,
Expand All @@ -46,67 +41,37 @@
has_project_prompts,
)
from .retry import execute_with_retry, retry_config_from_env
from .rate_limit import auto_continue_delay_from_rate_limit
from ..core.database import get_database


# Configuration
AUTO_CONTINUE_DELAY_SECONDS = 3


def _stop_when_done() -> bool:
raw = str(os.environ.get("AUTOCODER_STOP_WHEN_DONE", "")).strip().lower()
if not raw:
return True
return raw in {"1", "true", "yes", "on"}
def create_client(*args, **kwargs):
# Lazy import to avoid importing the Claude SDK on module import.
from .client import create_client as _create_client

return _create_client(*args, **kwargs)

def _auto_continue_delay_from_rate_limit(response: str) -> tuple[float, str | None]:
"""
If the Claude CLI indicates a rate limit reset time, return a delay (seconds)
until the reset and a human-readable target time string.

Expected pattern (Claude CLI):
"Limit reached ... Resets 5:30pm (America/Los_Angeles)"
"""
if not response:
return float(AUTO_CONTINUE_DELAY_SECONDS), None
if "limit reached" not in response.lower():
return float(AUTO_CONTINUE_DELAY_SECONDS), None
if ZoneInfo is None:
return float(AUTO_CONTINUE_DELAY_SECONDS), None

match = re.search(
r"(?i)\bresets(?:\s+at)?\s+(\d+)(?::(\d+))?\s*(am|pm)\s*\(([^)]+)\)",
response,
def _auto_continue_delay_from_rate_limit(response: str) -> tuple[float, str | None]:
# Backwards-compatible wrapper for tests and older call sites.
return auto_continue_delay_from_rate_limit(
response, default_delay_s=float(AUTO_CONTINUE_DELAY_SECONDS)
)
if not match:
return float(AUTO_CONTINUE_DELAY_SECONDS), None

hour = int(match.group(1))
minute = int(match.group(2)) if match.group(2) else 0
period = match.group(3).lower()
tz_name = match.group(4).strip()

if period == "pm" and hour != 12:
hour += 12
elif period == "am" and hour == 12:
hour = 0

try:
tz = ZoneInfo(tz_name)
now = datetime.now(tz)
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now:
target += timedelta(days=1)
delay = max(0.0, (target - now).total_seconds())
delay = min(delay, 24 * 60 * 60)
return delay, target.strftime("%B %d, %Y at %I:%M %p %Z")
except Exception:
return float(AUTO_CONTINUE_DELAY_SECONDS), None
def _stop_when_done() -> bool:
raw = str(os.environ.get("AUTOCODER_STOP_WHEN_DONE", "")).strip().lower()
if not raw:
return True
return raw in {"1", "true", "yes", "on"}


async def run_agent_session(
client: ClaudeSDKClient,
client: "ClaudeSDKClient",
message: str,
project_dir: Path,
) -> tuple[str, str]:
Expand Down Expand Up @@ -476,7 +441,9 @@ def _assigned_feature_done() -> bool:
# Handle status
if status == "continue":
limit_reached = bool(response) and "limit reached" in response.lower()
delay_s, target = _auto_continue_delay_from_rate_limit(response)
delay_s, target = auto_continue_delay_from_rate_limit(
response, default_delay_s=float(AUTO_CONTINUE_DELAY_SECONDS)
)
if limit_reached:
print("Claude Agent SDK indicated limit reached.", flush=True)
if target:
Expand Down
66 changes: 66 additions & 0 deletions src/autocoder/agent/rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from __future__ import annotations

import re
from datetime import datetime, timedelta

try:
from zoneinfo import ZoneInfo
except Exception: # pragma: no cover - very old Python / missing tzdata
ZoneInfo = None # type: ignore[assignment]


_RESET_RE = re.compile(r"(?i)\bresets(?:\s+at)?\s+(\d+)(?::(\d+))?\s*(am|pm)\s*\(([^)]+)\)")


def auto_continue_delay_from_rate_limit(
response: str,
*,
default_delay_s: float,
now: datetime | None = None,
) -> tuple[float, str | None]:
"""
If a Claude CLI response indicates a rate limit reset time, return a delay (seconds)
until the reset and a human-readable target time string.

Expected pattern (Claude CLI):
"Limit reached ... Resets 5:30pm (America/Los_Angeles)"
"""
if not response:
return float(default_delay_s), None
if "limit reached" not in response.lower():
return float(default_delay_s), None
if ZoneInfo is None:
return float(default_delay_s), None

match = _RESET_RE.search(response)
if not match:
return float(default_delay_s), None

hour = int(match.group(1))
minute = int(match.group(2)) if match.group(2) else 0
period = match.group(3).lower()
tz_name = match.group(4).strip()

if period == "pm" and hour != 12:
hour += 12
elif period == "am" and hour == 12:
hour = 0

try:
tz = ZoneInfo(tz_name)
now_tz = now
if now_tz is None:
now_tz = datetime.now(tz)
elif now_tz.tzinfo is None:
now_tz = now_tz.replace(tzinfo=tz)
else:
now_tz = now_tz.astimezone(tz)

target = now_tz.replace(hour=hour, minute=minute, second=0, microsecond=0)
if target <= now_tz:
target += timedelta(days=1)
delay = max(0.0, (target - now_tz).total_seconds())
delay = min(delay, 24 * 60 * 60)
return delay, target.strftime("%B %d, %Y at %I:%M %p %Z")
except Exception:
return float(default_delay_s), None
Loading