autoforge-ai 0.1.3 → 0.1.5

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.
@@ -26,7 +26,7 @@ from ..services.terminal_manager import (
26
26
  stop_terminal_session,
27
27
  )
28
28
  from ..utils.project_helpers import get_project_path as _get_project_path
29
- from ..utils.validation import is_valid_project_name as validate_project_name
29
+ from ..utils.validation import is_valid_project_name
30
30
 
31
31
  logger = logging.getLogger(__name__)
32
32
 
@@ -89,7 +89,7 @@ async def list_project_terminals(project_name: str) -> list[TerminalInfoResponse
89
89
  Returns:
90
90
  List of terminal info objects
91
91
  """
92
- if not validate_project_name(project_name):
92
+ if not is_valid_project_name(project_name):
93
93
  raise HTTPException(status_code=400, detail="Invalid project name")
94
94
 
95
95
  project_dir = _get_project_path(project_name)
@@ -122,7 +122,7 @@ async def create_project_terminal(
122
122
  Returns:
123
123
  The created terminal info
124
124
  """
125
- if not validate_project_name(project_name):
125
+ if not is_valid_project_name(project_name):
126
126
  raise HTTPException(status_code=400, detail="Invalid project name")
127
127
 
128
128
  project_dir = _get_project_path(project_name)
@@ -148,7 +148,7 @@ async def rename_project_terminal(
148
148
  Returns:
149
149
  The updated terminal info
150
150
  """
151
- if not validate_project_name(project_name):
151
+ if not is_valid_project_name(project_name):
152
152
  raise HTTPException(status_code=400, detail="Invalid project name")
153
153
 
154
154
  if not validate_terminal_id(terminal_id):
@@ -180,7 +180,7 @@ async def delete_project_terminal(project_name: str, terminal_id: str) -> dict:
180
180
  Returns:
181
181
  Success message
182
182
  """
183
- if not validate_project_name(project_name):
183
+ if not is_valid_project_name(project_name):
184
184
  raise HTTPException(status_code=400, detail="Invalid project name")
185
185
 
186
186
  if not validate_terminal_id(terminal_id):
@@ -221,8 +221,12 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
221
221
  - {"type": "pong"} - Keep-alive response
222
222
  - {"type": "error", "message": "..."} - Error message
223
223
  """
224
+ # Always accept WebSocket first to avoid opaque 403 errors
225
+ await websocket.accept()
226
+
224
227
  # Validate project name
225
- if not validate_project_name(project_name):
228
+ if not is_valid_project_name(project_name):
229
+ await websocket.send_json({"type": "error", "message": "Invalid project name"})
226
230
  await websocket.close(
227
231
  code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid project name"
228
232
  )
@@ -230,6 +234,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
230
234
 
231
235
  # Validate terminal ID
232
236
  if not validate_terminal_id(terminal_id):
237
+ await websocket.send_json({"type": "error", "message": "Invalid terminal ID"})
233
238
  await websocket.close(
234
239
  code=TerminalCloseCode.INVALID_PROJECT_NAME, reason="Invalid terminal ID"
235
240
  )
@@ -238,6 +243,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
238
243
  # Look up project directory from registry
239
244
  project_dir = _get_project_path(project_name)
240
245
  if not project_dir:
246
+ await websocket.send_json({"type": "error", "message": "Project not found in registry"})
241
247
  await websocket.close(
242
248
  code=TerminalCloseCode.PROJECT_NOT_FOUND,
243
249
  reason="Project not found in registry",
@@ -245,6 +251,7 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
245
251
  return
246
252
 
247
253
  if not project_dir.exists():
254
+ await websocket.send_json({"type": "error", "message": "Project directory not found"})
248
255
  await websocket.close(
249
256
  code=TerminalCloseCode.PROJECT_NOT_FOUND,
250
257
  reason="Project directory not found",
@@ -254,14 +261,13 @@ async def terminal_websocket(websocket: WebSocket, project_name: str, terminal_i
254
261
  # Verify terminal exists in metadata
255
262
  terminal_info = get_terminal_info(project_name, terminal_id)
256
263
  if not terminal_info:
264
+ await websocket.send_json({"type": "error", "message": "Terminal not found"})
257
265
  await websocket.close(
258
266
  code=TerminalCloseCode.PROJECT_NOT_FOUND,
259
267
  reason="Terminal not found",
260
268
  )
261
269
  return
262
270
 
263
- await websocket.accept()
264
-
265
271
  # Get or create terminal session for this project/terminal
266
272
  session = get_terminal_session(project_name, project_dir, terminal_id)
267
273
 
package/server/schemas.py CHANGED
@@ -391,15 +391,35 @@ class ModelInfo(BaseModel):
391
391
  name: str
392
392
 
393
393
 
394
+ class ProviderInfo(BaseModel):
395
+ """Information about an API provider."""
396
+ id: str
397
+ name: str
398
+ base_url: str | None = None
399
+ models: list[ModelInfo]
400
+ default_model: str
401
+ requires_auth: bool = False
402
+
403
+
404
+ class ProvidersResponse(BaseModel):
405
+ """Response schema for available providers list."""
406
+ providers: list[ProviderInfo]
407
+ current: str
408
+
409
+
394
410
  class SettingsResponse(BaseModel):
395
411
  """Response schema for global settings."""
396
412
  yolo_mode: bool = False
397
413
  model: str = DEFAULT_MODEL
398
- glm_mode: bool = False # True if GLM API is configured via .env
399
- ollama_mode: bool = False # True if Ollama API is configured via .env
414
+ glm_mode: bool = False # True when api_provider is "glm"
415
+ ollama_mode: bool = False # True when api_provider is "ollama"
400
416
  testing_agent_ratio: int = 1 # Regression testing agents (0-3)
401
417
  playwright_headless: bool = True
402
418
  batch_size: int = 3 # Features per coding agent batch (1-3)
419
+ api_provider: str = "claude"
420
+ api_base_url: str | None = None
421
+ api_has_auth_token: bool = False # Never expose actual token
422
+ api_model: str | None = None
403
423
 
404
424
 
405
425
  class ModelsResponse(BaseModel):
@@ -415,12 +435,30 @@ class SettingsUpdate(BaseModel):
415
435
  testing_agent_ratio: int | None = None # 0-3
416
436
  playwright_headless: bool | None = None
417
437
  batch_size: int | None = None # Features per agent batch (1-3)
438
+ api_provider: str | None = None
439
+ api_base_url: str | None = Field(None, max_length=500)
440
+ api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
441
+ api_model: str | None = Field(None, max_length=200)
442
+
443
+ @field_validator('api_base_url')
444
+ @classmethod
445
+ def validate_api_base_url(cls, v: str | None) -> str | None:
446
+ if v is not None and v.strip():
447
+ v = v.strip()
448
+ if not v.startswith(("http://", "https://")):
449
+ raise ValueError("api_base_url must start with http:// or https://")
450
+ return v
418
451
 
419
452
  @field_validator('model')
420
453
  @classmethod
421
- def validate_model(cls, v: str | None) -> str | None:
422
- if v is not None and v not in VALID_MODELS:
423
- raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
454
+ def validate_model(cls, v: str | None, info) -> str | None: # type: ignore[override]
455
+ if v is not None:
456
+ # Skip VALID_MODELS check when using an alternative API provider
457
+ api_provider = info.data.get("api_provider")
458
+ if api_provider and api_provider != "claude":
459
+ return v
460
+ if v not in VALID_MODELS:
461
+ raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
424
462
  return v
425
463
 
426
464
  @field_validator('testing_agent_ratio')
@@ -25,7 +25,7 @@ from .assistant_database import (
25
25
  create_conversation,
26
26
  get_messages,
27
27
  )
28
- from .chat_constants import API_ENV_VARS, ROOT_DIR
28
+ from .chat_constants import ROOT_DIR
29
29
 
30
30
  # Load environment variables from .env file if present
31
31
  load_dotenv()
@@ -157,7 +157,7 @@ class AssistantChatSession:
157
157
  """
158
158
  Manages a read-only assistant conversation for a project.
159
159
 
160
- Uses Claude Opus 4.5 with only read-only tools enabled.
160
+ Uses Claude Opus with only read-only tools enabled.
161
161
  Persists conversation history to SQLite.
162
162
  """
163
163
 
@@ -258,15 +258,11 @@ class AssistantChatSession:
258
258
  system_cli = shutil.which("claude")
259
259
 
260
260
  # Build environment overrides for API configuration
261
- sdk_env: dict[str, str] = {}
262
- for var in API_ENV_VARS:
263
- value = os.getenv(var)
264
- if value:
265
- sdk_env[var] = value
266
-
267
- # Determine model from environment or use default
268
- # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
269
- model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
261
+ from registry import DEFAULT_MODEL, get_effective_sdk_env
262
+ sdk_env = get_effective_sdk_env()
263
+
264
+ # Determine model from SDK env (provider-aware) or fallback to env/default
265
+ model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
270
266
 
271
267
  try:
272
268
  logger.info("Creating ClaudeSDKClient...")
@@ -22,7 +22,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
22
22
  from dotenv import load_dotenv
23
23
 
24
24
  from ..schemas import ImageAttachment
25
- from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
25
+ from .chat_constants import ROOT_DIR, make_multimodal_message
26
26
 
27
27
  # Load environment variables from .env file if present
28
28
  load_dotenv()
@@ -154,16 +154,11 @@ class ExpandChatSession:
154
154
  system_prompt = skill_content.replace("$ARGUMENTS", project_path)
155
155
 
156
156
  # Build environment overrides for API configuration
157
- # Filter to only include vars that are actually set (non-None)
158
- sdk_env: dict[str, str] = {}
159
- for var in API_ENV_VARS:
160
- value = os.getenv(var)
161
- if value:
162
- sdk_env[var] = value
163
-
164
- # Determine model from environment or use default
165
- # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
166
- model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
157
+ from registry import DEFAULT_MODEL, get_effective_sdk_env
158
+ sdk_env = get_effective_sdk_env()
159
+
160
+ # Determine model from SDK env (provider-aware) or fallback to env/default
161
+ model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
167
162
 
168
163
  # Build MCP servers config for feature creation
169
164
  mcp_servers = {
@@ -227,6 +227,46 @@ class AgentProcessManager:
227
227
  """Remove lock file."""
228
228
  self.lock_file.unlink(missing_ok=True)
229
229
 
230
+ def _cleanup_stale_features(self) -> None:
231
+ """Clear in_progress flag for all features when agent stops/crashes.
232
+
233
+ When the agent process exits (normally or crash), any features left
234
+ with in_progress=True were being worked on and didn't complete.
235
+ Reset them so they can be picked up on next agent start.
236
+ """
237
+ try:
238
+ from autoforge_paths import get_features_db_path
239
+ features_db = get_features_db_path(self.project_dir)
240
+ if not features_db.exists():
241
+ return
242
+
243
+ from sqlalchemy import create_engine
244
+ from sqlalchemy.orm import sessionmaker
245
+
246
+ from api.database import Feature
247
+
248
+ engine = create_engine(f"sqlite:///{features_db}")
249
+ Session = sessionmaker(bind=engine)
250
+ session = Session()
251
+ try:
252
+ stuck = session.query(Feature).filter(
253
+ Feature.in_progress == True, # noqa: E712
254
+ Feature.passes == False, # noqa: E712
255
+ ).all()
256
+ if stuck:
257
+ for f in stuck:
258
+ f.in_progress = False
259
+ session.commit()
260
+ logger.info(
261
+ "Cleaned up %d stuck feature(s) for %s",
262
+ len(stuck), self.project_name,
263
+ )
264
+ finally:
265
+ session.close()
266
+ engine.dispose()
267
+ except Exception as e:
268
+ logger.warning("Failed to cleanup features for %s: %s", self.project_name, e)
269
+
230
270
  async def _broadcast_output(self, line: str) -> None:
231
271
  """Broadcast output line to all registered callbacks."""
232
272
  with self._callbacks_lock:
@@ -288,6 +328,7 @@ class AgentProcessManager:
288
328
  self.status = "crashed"
289
329
  elif self.status == "running":
290
330
  self.status = "stopped"
331
+ self._cleanup_stale_features()
291
332
  self._remove_lock()
292
333
 
293
334
  async def start(
@@ -305,7 +346,7 @@ class AgentProcessManager:
305
346
 
306
347
  Args:
307
348
  yolo_mode: If True, run in YOLO mode (skip testing agents)
308
- model: Model to use (e.g., claude-opus-4-5-20251101)
349
+ model: Model to use (e.g., claude-opus-4-6)
309
350
  parallel_mode: DEPRECATED - ignored, always uses unified orchestrator
310
351
  max_concurrency: Max concurrent coding agents (1-5, default 1)
311
352
  testing_agent_ratio: Number of regression testing agents (0-3, default 1)
@@ -320,6 +361,9 @@ class AgentProcessManager:
320
361
  if not self._check_lock():
321
362
  return False, "Another agent instance is already running for this project"
322
363
 
364
+ # Clean up features stuck from a previous crash/stop
365
+ self._cleanup_stale_features()
366
+
323
367
  # Store for status queries
324
368
  self.yolo_mode = yolo_mode
325
369
  self.model = model
@@ -359,12 +403,22 @@ class AgentProcessManager:
359
403
  # stdin=DEVNULL prevents blocking if Claude CLI or child process tries to read stdin
360
404
  # CREATE_NO_WINDOW on Windows prevents console window pop-ups
361
405
  # PYTHONUNBUFFERED ensures output isn't delayed
406
+ # Build subprocess environment with API provider settings
407
+ from registry import get_effective_sdk_env
408
+ api_env = get_effective_sdk_env()
409
+ subprocess_env = {
410
+ **os.environ,
411
+ "PYTHONUNBUFFERED": "1",
412
+ "PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false",
413
+ **api_env,
414
+ }
415
+
362
416
  popen_kwargs: dict[str, Any] = {
363
417
  "stdin": subprocess.DEVNULL,
364
418
  "stdout": subprocess.PIPE,
365
419
  "stderr": subprocess.STDOUT,
366
420
  "cwd": str(self.project_dir),
367
- "env": {**os.environ, "PYTHONUNBUFFERED": "1", "PLAYWRIGHT_HEADLESS": "true" if playwright_headless else "false"},
421
+ "env": subprocess_env,
368
422
  }
369
423
  if sys.platform == "win32":
370
424
  popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
@@ -425,6 +479,7 @@ class AgentProcessManager:
425
479
  result.children_terminated, result.children_killed
426
480
  )
427
481
 
482
+ self._cleanup_stale_features()
428
483
  self._remove_lock()
429
484
  self.status = "stopped"
430
485
  self.process = None
@@ -502,6 +557,7 @@ class AgentProcessManager:
502
557
  if poll is not None:
503
558
  # Process has terminated
504
559
  if self.status in ("running", "paused"):
560
+ self._cleanup_stale_features()
505
561
  self.status = "crashed"
506
562
  self._remove_lock()
507
563
  return False
@@ -19,7 +19,7 @@ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
19
19
  from dotenv import load_dotenv
20
20
 
21
21
  from ..schemas import ImageAttachment
22
- from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
22
+ from .chat_constants import ROOT_DIR, make_multimodal_message
23
23
 
24
24
  # Load environment variables from .env file if present
25
25
  load_dotenv()
@@ -140,16 +140,11 @@ class SpecChatSession:
140
140
  system_cli = shutil.which("claude")
141
141
 
142
142
  # Build environment overrides for API configuration
143
- # Filter to only include vars that are actually set (non-None)
144
- sdk_env: dict[str, str] = {}
145
- for var in API_ENV_VARS:
146
- value = os.getenv(var)
147
- if value:
148
- sdk_env[var] = value
149
-
150
- # Determine model from environment or use default
151
- # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
152
- model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
143
+ from registry import DEFAULT_MODEL, get_effective_sdk_env
144
+ sdk_env = get_effective_sdk_env()
145
+
146
+ # Determine model from SDK env (provider-aware) or fallback to env/default
147
+ model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
153
148
 
154
149
  try:
155
150
  self.client = ClaudeSDKClient(
@@ -640,9 +640,7 @@ class ConnectionManager:
640
640
  self._lock = asyncio.Lock()
641
641
 
642
642
  async def connect(self, websocket: WebSocket, project_name: str):
643
- """Accept a WebSocket connection for a project."""
644
- await websocket.accept()
645
-
643
+ """Register a WebSocket connection for a project (must already be accepted)."""
646
644
  async with self._lock:
647
645
  if project_name not in self.active_connections:
648
646
  self.active_connections[project_name] = set()
@@ -727,16 +725,22 @@ async def project_websocket(websocket: WebSocket, project_name: str):
727
725
  - Agent status changes
728
726
  - Agent stdout/stderr lines
729
727
  """
728
+ # Always accept WebSocket first to avoid opaque 403 errors
729
+ await websocket.accept()
730
+
730
731
  if not validate_project_name(project_name):
732
+ await websocket.send_json({"type": "error", "content": "Invalid project name"})
731
733
  await websocket.close(code=4000, reason="Invalid project name")
732
734
  return
733
735
 
734
736
  project_dir = _get_project_path(project_name)
735
737
  if not project_dir:
738
+ await websocket.send_json({"type": "error", "content": "Project not found in registry"})
736
739
  await websocket.close(code=4004, reason="Project not found in registry")
737
740
  return
738
741
 
739
742
  if not project_dir.exists():
743
+ await websocket.send_json({"type": "error", "content": "Project directory not found"})
740
744
  await websocket.close(code=4004, reason="Project directory not found")
741
745
  return
742
746
 
@@ -879,8 +883,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
879
883
  break
880
884
  except json.JSONDecodeError:
881
885
  logger.warning(f"Invalid JSON from WebSocket: {data[:100] if data else 'empty'}")
882
- except Exception as e:
883
- logger.warning(f"WebSocket error: {e}")
886
+ except Exception:
884
887
  break
885
888
 
886
889
  finally: