autoforge-ai 0.1.14 → 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agent.py CHANGED
@@ -74,46 +74,65 @@ async def run_agent_session(
74
74
  await client.query(message)
75
75
 
76
76
  # Collect response text and show tool use
77
+ # Retry receive_response() on MessageParseError — the SDK raises this for
78
+ # unknown CLI message types (e.g. "rate_limit_event") which kills the async
79
+ # generator. The subprocess is still alive so we restart to read remaining
80
+ # messages from the buffered channel.
77
81
  response_text = ""
78
- async for msg in client.receive_response():
79
- msg_type = type(msg).__name__
80
-
81
- # Handle AssistantMessage (text and tool use)
82
- if msg_type == "AssistantMessage" and hasattr(msg, "content"):
83
- for block in msg.content:
84
- block_type = type(block).__name__
85
-
86
- if block_type == "TextBlock" and hasattr(block, "text"):
87
- response_text += block.text
88
- print(block.text, end="", flush=True)
89
- elif block_type == "ToolUseBlock" and hasattr(block, "name"):
90
- print(f"\n[Tool: {block.name}]", flush=True)
91
- if hasattr(block, "input"):
92
- input_str = str(block.input)
93
- if len(input_str) > 200:
94
- print(f" Input: {input_str[:200]}...", flush=True)
95
- else:
96
- print(f" Input: {input_str}", flush=True)
97
-
98
- # Handle UserMessage (tool results)
99
- elif msg_type == "UserMessage" and hasattr(msg, "content"):
100
- for block in msg.content:
101
- block_type = type(block).__name__
102
-
103
- if block_type == "ToolResultBlock":
104
- result_content = getattr(block, "content", "")
105
- is_error = getattr(block, "is_error", False)
106
-
107
- # Check if command was blocked by security hook
108
- if "blocked" in str(result_content).lower():
109
- print(f" [BLOCKED] {result_content}", flush=True)
110
- elif is_error:
111
- # Show errors (truncated)
112
- error_str = str(result_content)[:500]
113
- print(f" [Error] {error_str}", flush=True)
114
- else:
115
- # Tool succeeded - just show brief confirmation
116
- print(" [Done]", flush=True)
82
+ max_parse_retries = 50
83
+ parse_retries = 0
84
+ while True:
85
+ try:
86
+ async for msg in client.receive_response():
87
+ msg_type = type(msg).__name__
88
+
89
+ # Handle AssistantMessage (text and tool use)
90
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
91
+ for block in msg.content:
92
+ block_type = type(block).__name__
93
+
94
+ if block_type == "TextBlock" and hasattr(block, "text"):
95
+ response_text += block.text
96
+ print(block.text, end="", flush=True)
97
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
98
+ print(f"\n[Tool: {block.name}]", flush=True)
99
+ if hasattr(block, "input"):
100
+ input_str = str(block.input)
101
+ if len(input_str) > 200:
102
+ print(f" Input: {input_str[:200]}...", flush=True)
103
+ else:
104
+ print(f" Input: {input_str}", flush=True)
105
+
106
+ # Handle UserMessage (tool results)
107
+ elif msg_type == "UserMessage" and hasattr(msg, "content"):
108
+ for block in msg.content:
109
+ block_type = type(block).__name__
110
+
111
+ if block_type == "ToolResultBlock":
112
+ result_content = getattr(block, "content", "")
113
+ is_error = getattr(block, "is_error", False)
114
+
115
+ # Check if command was blocked by security hook
116
+ if "blocked" in str(result_content).lower():
117
+ print(f" [BLOCKED] {result_content}", flush=True)
118
+ elif is_error:
119
+ # Show errors (truncated)
120
+ error_str = str(result_content)[:500]
121
+ print(f" [Error] {error_str}", flush=True)
122
+ else:
123
+ # Tool succeeded - just show brief confirmation
124
+ print(" [Done]", flush=True)
125
+
126
+ break # Normal completion
127
+ except Exception as inner_exc:
128
+ if type(inner_exc).__name__ == "MessageParseError":
129
+ parse_retries += 1
130
+ if parse_retries > max_parse_retries:
131
+ print(f"Too many unrecognized CLI messages ({parse_retries}), stopping")
132
+ break
133
+ print(f"Ignoring unrecognized message from Claude CLI: {inner_exc}")
134
+ continue
135
+ raise # Re-raise to outer except
117
136
 
118
137
  print("\n" + "-" * 70 + "\n")
119
138
  return "continue", response_text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "autoforge-ai",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Autonomous coding agent with web UI - build complete apps with AI",
5
5
  "license": "AGPL-3.0",
6
6
  "bin": {
package/server/main.py CHANGED
@@ -36,6 +36,7 @@ from .routers import (
36
36
  features_router,
37
37
  filesystem_router,
38
38
  projects_router,
39
+ scaffold_router,
39
40
  schedules_router,
40
41
  settings_router,
41
42
  spec_creation_router,
@@ -169,6 +170,7 @@ app.include_router(filesystem_router)
169
170
  app.include_router(assistant_chat_router)
170
171
  app.include_router(settings_router)
171
172
  app.include_router(terminal_router)
173
+ app.include_router(scaffold_router)
172
174
 
173
175
 
174
176
  # ============================================================================
@@ -12,6 +12,7 @@ from .expand_project import router as expand_project_router
12
12
  from .features import router as features_router
13
13
  from .filesystem import router as filesystem_router
14
14
  from .projects import router as projects_router
15
+ from .scaffold import router as scaffold_router
15
16
  from .schedules import router as schedules_router
16
17
  from .settings import router as settings_router
17
18
  from .spec_creation import router as spec_creation_router
@@ -29,4 +30,5 @@ __all__ = [
29
30
  "assistant_chat_router",
30
31
  "settings_router",
31
32
  "terminal_router",
33
+ "scaffold_router",
32
34
  ]
@@ -0,0 +1,136 @@
1
+ """
2
+ Scaffold Router
3
+ ================
4
+
5
+ SSE streaming endpoint for running project scaffold commands.
6
+ Supports templated project creation (e.g., Next.js agentic starter).
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from fastapi import APIRouter, Request
18
+ from fastapi.responses import StreamingResponse
19
+ from pydantic import BaseModel
20
+
21
+ from .filesystem import is_path_blocked
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ router = APIRouter(prefix="/api/scaffold", tags=["scaffold"])
26
+
27
+ # Hardcoded templates — no arbitrary commands allowed
28
+ TEMPLATES: dict[str, list[str]] = {
29
+ "agentic-starter": ["npx", "create-agentic-app@latest", ".", "-y", "-p", "npm", "--skip-git"],
30
+ }
31
+
32
+
33
+ class ScaffoldRequest(BaseModel):
34
+ template: str
35
+ target_path: str
36
+
37
+
38
+ def _sse_event(data: dict) -> str:
39
+ """Format a dict as an SSE data line."""
40
+ return f"data: {json.dumps(data)}\n\n"
41
+
42
+
43
+ async def _stream_scaffold(template: str, target_path: str, request: Request):
44
+ """Run the scaffold command and yield SSE events."""
45
+ # Validate template
46
+ if template not in TEMPLATES:
47
+ yield _sse_event({"type": "error", "message": f"Unknown template: {template}"})
48
+ return
49
+
50
+ # Validate path
51
+ path = Path(target_path)
52
+ try:
53
+ path = path.resolve()
54
+ except (OSError, ValueError) as e:
55
+ yield _sse_event({"type": "error", "message": f"Invalid path: {e}"})
56
+ return
57
+
58
+ if is_path_blocked(path):
59
+ yield _sse_event({"type": "error", "message": "Access to this directory is not allowed"})
60
+ return
61
+
62
+ if not path.exists() or not path.is_dir():
63
+ yield _sse_event({"type": "error", "message": "Target directory does not exist"})
64
+ return
65
+
66
+ # Check npx is available
67
+ npx_name = "npx"
68
+ if sys.platform == "win32":
69
+ npx_name = "npx.cmd"
70
+
71
+ if not shutil.which(npx_name):
72
+ yield _sse_event({"type": "error", "message": "npx is not available. Please install Node.js."})
73
+ return
74
+
75
+ # Build command
76
+ argv = list(TEMPLATES[template])
77
+ if sys.platform == "win32" and not argv[0].lower().endswith(".cmd"):
78
+ argv[0] = argv[0] + ".cmd"
79
+
80
+ process = None
81
+ try:
82
+ popen_kwargs: dict = {
83
+ "stdout": subprocess.PIPE,
84
+ "stderr": subprocess.STDOUT,
85
+ "stdin": subprocess.DEVNULL,
86
+ "cwd": str(path),
87
+ }
88
+ if sys.platform == "win32":
89
+ popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
90
+
91
+ process = subprocess.Popen(argv, **popen_kwargs)
92
+ logger.info("Scaffold process started: pid=%s, template=%s, path=%s", process.pid, template, target_path)
93
+
94
+ # Stream stdout lines
95
+ assert process.stdout is not None
96
+ for raw_line in iter(process.stdout.readline, b""):
97
+ # Check if client disconnected
98
+ if await request.is_disconnected():
99
+ logger.info("Client disconnected during scaffold, terminating process")
100
+ break
101
+
102
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n\r")
103
+ yield _sse_event({"type": "output", "line": line})
104
+ # Yield control to event loop so disconnect checks work
105
+ await asyncio.sleep(0)
106
+
107
+ process.wait()
108
+ exit_code = process.returncode
109
+ success = exit_code == 0
110
+ logger.info("Scaffold process completed: exit_code=%s, template=%s", exit_code, template)
111
+ yield _sse_event({"type": "complete", "success": success, "exit_code": exit_code})
112
+
113
+ except Exception as e:
114
+ logger.error("Scaffold error: %s", e)
115
+ yield _sse_event({"type": "error", "message": str(e)})
116
+
117
+ finally:
118
+ if process and process.poll() is None:
119
+ try:
120
+ process.terminate()
121
+ process.wait(timeout=5)
122
+ except Exception:
123
+ process.kill()
124
+
125
+
126
+ @router.post("/run")
127
+ async def run_scaffold(body: ScaffoldRequest, request: Request):
128
+ """Run a scaffold template command with SSE streaming output."""
129
+ return StreamingResponse(
130
+ _stream_scaffold(body.template, body.target_path, request),
131
+ media_type="text/event-stream",
132
+ headers={
133
+ "Cache-Control": "no-cache",
134
+ "X-Accel-Buffering": "no",
135
+ },
136
+ )
@@ -7,7 +7,6 @@ The assistant can answer questions about the codebase and features
7
7
  but cannot modify any files.
8
8
  """
9
9
 
10
- import asyncio
11
10
  import json
12
11
  import logging
13
12
  import os
@@ -27,10 +26,9 @@ from .assistant_database import (
27
26
  get_messages,
28
27
  )
29
28
  from .chat_constants import (
30
- MAX_CHAT_RATE_LIMIT_RETRIES,
31
29
  ROOT_DIR,
32
- calculate_rate_limit_backoff,
33
30
  check_rate_limit_error,
31
+ safe_receive_response,
34
32
  )
35
33
 
36
34
  # Load environment variables from .env file if present
@@ -399,66 +397,47 @@ class AssistantChatSession:
399
397
 
400
398
  full_response = ""
401
399
 
402
- # Stream the response (with rate-limit retry)
403
- for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1):
404
- try:
405
- async for msg in self.client.receive_response():
406
- msg_type = type(msg).__name__
407
-
408
- if msg_type == "AssistantMessage" and hasattr(msg, "content"):
409
- for block in msg.content:
410
- block_type = type(block).__name__
411
-
412
- if block_type == "TextBlock" and hasattr(block, "text"):
413
- text = block.text
414
- if text:
415
- full_response += text
416
- yield {"type": "text", "content": text}
417
-
418
- elif block_type == "ToolUseBlock" and hasattr(block, "name"):
419
- tool_name = block.name
420
- tool_input = getattr(block, "input", {})
421
-
422
- # Intercept ask_user tool calls -> yield as question message
423
- if tool_name == "mcp__features__ask_user":
424
- questions = tool_input.get("questions", [])
425
- if questions:
426
- yield {
427
- "type": "question",
428
- "questions": questions,
429
- }
430
- continue
431
-
432
- yield {
433
- "type": "tool_call",
434
- "tool": tool_name,
435
- "input": tool_input,
436
- }
437
- # Completed successfully — break out of retry loop
438
- break
439
- except Exception as exc:
440
- is_rate_limit, retry_secs = check_rate_limit_error(exc)
441
- if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES:
442
- delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt)
443
- logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s")
444
- yield {
445
- "type": "rate_limited",
446
- "retry_in": delay,
447
- "attempt": _attempt + 1,
448
- "max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
449
- }
450
- await asyncio.sleep(delay)
451
- await self.client.query(message)
452
- continue
453
- if is_rate_limit:
454
- logger.error("Rate limit retries exhausted for assistant chat")
455
- yield {"type": "error", "content": "Rate limited. Please try again later."}
456
- return
457
- # Non-rate-limit MessageParseError: log and break (don't crash)
458
- if type(exc).__name__ == "MessageParseError":
459
- logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
460
- break
461
- raise
400
+ # Stream the response
401
+ try:
402
+ async for msg in safe_receive_response(self.client, logger):
403
+ msg_type = type(msg).__name__
404
+
405
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
406
+ for block in msg.content:
407
+ block_type = type(block).__name__
408
+
409
+ if block_type == "TextBlock" and hasattr(block, "text"):
410
+ text = block.text
411
+ if text:
412
+ full_response += text
413
+ yield {"type": "text", "content": text}
414
+
415
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
416
+ tool_name = block.name
417
+ tool_input = getattr(block, "input", {})
418
+
419
+ # Intercept ask_user tool calls -> yield as question message
420
+ if tool_name == "mcp__features__ask_user":
421
+ questions = tool_input.get("questions", [])
422
+ if questions:
423
+ yield {
424
+ "type": "question",
425
+ "questions": questions,
426
+ }
427
+ continue
428
+
429
+ yield {
430
+ "type": "tool_call",
431
+ "tool": tool_name,
432
+ "input": tool_input,
433
+ }
434
+ except Exception as exc:
435
+ is_rate_limit, _ = check_rate_limit_error(exc)
436
+ if is_rate_limit:
437
+ logger.warning(f"Rate limited: {exc}")
438
+ yield {"type": "error", "content": "Rate limited. Please try again later."}
439
+ return
440
+ raise
462
441
 
463
442
  # Store the complete response in the database
464
443
  if full_response and self.conversation_id:
@@ -12,7 +12,7 @@ imports (``from .chat_constants import API_ENV_VARS``) continue to work.
12
12
  import logging
13
13
  import sys
14
14
  from pathlib import Path
15
- from typing import AsyncGenerator
15
+ from typing import Any, AsyncGenerator
16
16
 
17
17
  # -------------------------------------------------------------------
18
18
  # Root directory of the autoforge project (repository root).
@@ -33,15 +33,10 @@ if _root_str not in sys.path:
33
33
  # imports continue to work unchanged.
34
34
  # -------------------------------------------------------------------
35
35
  from env_constants import API_ENV_VARS # noqa: E402, F401
36
- from rate_limit_utils import calculate_rate_limit_backoff, is_rate_limit_error, parse_retry_after # noqa: E402, F401
36
+ from rate_limit_utils import is_rate_limit_error, parse_retry_after # noqa: E402, F401
37
37
 
38
38
  logger = logging.getLogger(__name__)
39
39
 
40
- # -------------------------------------------------------------------
41
- # Rate-limit handling for chat sessions
42
- # -------------------------------------------------------------------
43
- MAX_CHAT_RATE_LIMIT_RETRIES = 3
44
-
45
40
 
46
41
  def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
47
42
  """Inspect an exception and determine if it represents a rate-limit.
@@ -49,24 +44,15 @@ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
49
44
  Returns ``(is_rate_limit, retry_seconds)``. ``retry_seconds`` is the
50
45
  parsed Retry-After value when available, otherwise ``None`` (caller
51
46
  should use exponential backoff).
52
-
53
- Handles:
54
- - ``MessageParseError`` whose raw *data* dict has
55
- ``type == "rate_limit_event"`` (Claude CLI sends this).
56
- - Any exception whose string representation matches known rate-limit
57
- patterns (via ``rate_limit_utils.is_rate_limit_error``).
58
47
  """
59
- exc_str = str(exc)
60
-
61
- # Check for MessageParseError with a rate_limit_event payload
62
- cls_name = type(exc).__name__
63
- if cls_name == "MessageParseError":
64
- raw_data = getattr(exc, "data", None)
65
- if isinstance(raw_data, dict) and raw_data.get("type") == "rate_limit_event":
66
- retry = parse_retry_after(str(raw_data)) if raw_data else None
67
- return True, retry
48
+ # MessageParseError = unknown CLI message type (e.g. "rate_limit_event").
49
+ # These are informational events, NOT actual rate limit errors.
50
+ # The word "rate_limit" in the type name would false-positive the regex.
51
+ if type(exc).__name__ == "MessageParseError":
52
+ return False, None
68
53
 
69
- # Fallback: match error text against known rate-limit patterns
54
+ # For all other exceptions: match error text against known rate-limit patterns
55
+ exc_str = str(exc)
70
56
  if is_rate_limit_error(exc_str):
71
57
  retry = parse_retry_after(exc_str)
72
58
  return True, retry
@@ -74,6 +60,34 @@ def check_rate_limit_error(exc: Exception) -> tuple[bool, int | None]:
74
60
  return False, None
75
61
 
76
62
 
63
+ async def safe_receive_response(client: Any, log: logging.Logger) -> AsyncGenerator:
64
+ """Wrap ``client.receive_response()`` to skip ``MessageParseError``.
65
+
66
+ The Claude Code CLI may emit message types (e.g. ``rate_limit_event``)
67
+ that the installed Python SDK does not recognise, causing
68
+ ``MessageParseError`` which kills the async generator. The CLI
69
+ subprocess is still alive and the SDK uses a buffered memory channel,
70
+ so we restart ``receive_response()`` to continue reading remaining
71
+ messages without losing data.
72
+ """
73
+ max_retries = 50
74
+ retries = 0
75
+ while True:
76
+ try:
77
+ async for msg in client.receive_response():
78
+ yield msg
79
+ return # Normal completion
80
+ except Exception as exc:
81
+ if type(exc).__name__ == "MessageParseError":
82
+ retries += 1
83
+ if retries > max_retries:
84
+ log.error(f"Too many unrecognized CLI messages ({retries}), stopping")
85
+ return
86
+ log.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
87
+ continue
88
+ raise
89
+
90
+
77
91
  async def make_multimodal_message(content_blocks: list[dict]) -> AsyncGenerator[dict, None]:
78
92
  """Yield a single multimodal user message in Claude Agent SDK format.
79
93
 
@@ -23,11 +23,10 @@ from dotenv import load_dotenv
23
23
 
24
24
  from ..schemas import ImageAttachment
25
25
  from .chat_constants import (
26
- MAX_CHAT_RATE_LIMIT_RETRIES,
27
26
  ROOT_DIR,
28
- calculate_rate_limit_backoff,
29
27
  check_rate_limit_error,
30
28
  make_multimodal_message,
29
+ safe_receive_response,
31
30
  )
32
31
 
33
32
  # Load environment variables from .env file if present
@@ -304,67 +303,32 @@ class ExpandChatSession:
304
303
  else:
305
304
  await self.client.query(message)
306
305
 
307
- # Stream the response (with rate-limit retry)
308
- for _attempt in range(MAX_CHAT_RATE_LIMIT_RETRIES + 1):
309
- try:
310
- async for msg in self.client.receive_response():
311
- msg_type = type(msg).__name__
312
-
313
- if msg_type == "AssistantMessage" and hasattr(msg, "content"):
314
- for block in msg.content:
315
- block_type = type(block).__name__
316
-
317
- if block_type == "TextBlock" and hasattr(block, "text"):
318
- text = block.text
319
- if text:
320
- yield {"type": "text", "content": text}
321
-
322
- self.messages.append({
323
- "role": "assistant",
324
- "content": text,
325
- "timestamp": datetime.now().isoformat()
326
- })
327
- # Completed successfully — break out of retry loop
328
- break
329
- except Exception as exc:
330
- is_rate_limit, retry_secs = check_rate_limit_error(exc)
331
- if is_rate_limit and _attempt < MAX_CHAT_RATE_LIMIT_RETRIES:
332
- delay = retry_secs if retry_secs else calculate_rate_limit_backoff(_attempt)
333
- logger.warning(f"Rate limited (attempt {_attempt + 1}/{MAX_CHAT_RATE_LIMIT_RETRIES}), retrying in {delay}s")
334
- yield {
335
- "type": "rate_limited",
336
- "retry_in": delay,
337
- "attempt": _attempt + 1,
338
- "max_attempts": MAX_CHAT_RATE_LIMIT_RETRIES,
339
- }
340
- await asyncio.sleep(delay)
341
- # Re-send the query before retrying receive_response
342
- if attachments and len(attachments) > 0:
343
- content_blocks_retry: list[dict[str, Any]] = []
344
- if message:
345
- content_blocks_retry.append({"type": "text", "text": message})
346
- for att in attachments:
347
- content_blocks_retry.append({
348
- "type": "image",
349
- "source": {
350
- "type": "base64",
351
- "media_type": att.mimeType,
352
- "data": att.base64Data,
353
- }
354
- })
355
- await self.client.query(make_multimodal_message(content_blocks_retry))
356
- else:
357
- await self.client.query(message)
358
- continue
359
- if is_rate_limit:
360
- logger.error("Rate limit retries exhausted for expand chat")
361
- yield {"type": "error", "content": "Rate limited. Please try again later."}
362
- return
363
- # Non-rate-limit MessageParseError: log and break (don't crash)
364
- if type(exc).__name__ == "MessageParseError":
365
- logger.warning(f"Ignoring unrecognized message from Claude CLI: {exc}")
366
- break
367
- raise
306
+ # Stream the response
307
+ try:
308
+ async for msg in safe_receive_response(self.client, logger):
309
+ msg_type = type(msg).__name__
310
+
311
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
312
+ for block in msg.content:
313
+ block_type = type(block).__name__
314
+
315
+ if block_type == "TextBlock" and hasattr(block, "text"):
316
+ text = block.text
317
+ if text:
318
+ yield {"type": "text", "content": text}
319
+
320
+ self.messages.append({
321
+ "role": "assistant",
322
+ "content": text,
323
+ "timestamp": datetime.now().isoformat()
324
+ })
325
+ except Exception as exc:
326
+ is_rate_limit, _ = check_rate_limit_error(exc)
327
+ if is_rate_limit:
328
+ logger.warning(f"Rate limited: {exc}")
329
+ yield {"type": "error", "content": "Rate limited. Please try again later."}
330
+ return
331
+ raise
368
332
 
369
333
  def get_features_created(self) -> int:
370
334
  """Get the total number of features created in this session."""