autoforge-ai 0.1.21 → 0.1.22

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.
@@ -66,10 +66,12 @@ def _get_registry_functions():
66
66
  sys.path.insert(0, str(root))
67
67
 
68
68
  from registry import (
69
+ get_project_auto_improve,
69
70
  get_project_concurrency,
70
71
  get_project_path,
71
72
  list_registered_projects,
72
73
  register_project,
74
+ set_project_auto_improve,
73
75
  set_project_concurrency,
74
76
  unregister_project,
75
77
  validate_project_path,
@@ -82,6 +84,8 @@ def _get_registry_functions():
82
84
  validate_project_path,
83
85
  get_project_concurrency,
84
86
  set_project_concurrency,
87
+ get_project_auto_improve,
88
+ set_project_auto_improve,
85
89
  )
86
90
 
87
91
 
@@ -118,7 +122,7 @@ async def list_projects():
118
122
  _init_imports()
119
123
  assert _check_spec_exists is not None # guaranteed by _init_imports()
120
124
  (_, _, _, list_registered_projects, validate_project_path,
121
- get_project_concurrency, _) = _get_registry_functions()
125
+ get_project_concurrency, _, _, _) = _get_registry_functions()
122
126
 
123
127
  projects = list_registered_projects()
124
128
  result = []
@@ -140,6 +144,10 @@ async def list_projects():
140
144
  has_spec=has_spec,
141
145
  stats=stats,
142
146
  default_concurrency=info.get("default_concurrency", 3),
147
+ auto_improve_enabled=bool(info.get("auto_improve_enabled", False)),
148
+ auto_improve_interval_minutes=int(
149
+ info.get("auto_improve_interval_minutes", 10) or 10
150
+ ),
143
151
  ))
144
152
 
145
153
  return result
@@ -151,7 +159,7 @@ async def create_project(project: ProjectCreate):
151
159
  _init_imports()
152
160
  assert _scaffold_project_prompts is not None # guaranteed by _init_imports()
153
161
  (register_project, _, get_project_path, list_registered_projects,
154
- _, _, _) = _get_registry_functions()
162
+ _, _, _, _, _) = _get_registry_functions()
155
163
 
156
164
  name = validate_project_name(project.name)
157
165
  project_path = Path(project.path).resolve()
@@ -232,7 +240,8 @@ async def get_project(name: str):
232
240
  _init_imports()
233
241
  assert _check_spec_exists is not None # guaranteed by _init_imports()
234
242
  assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
235
- (_, _, get_project_path, _, _, get_project_concurrency, _) = _get_registry_functions()
243
+ (_, _, get_project_path, _, _, get_project_concurrency, _,
244
+ get_project_auto_improve, _) = _get_registry_functions()
236
245
 
237
246
  name = validate_project_name(name)
238
247
  project_dir = get_project_path(name)
@@ -246,6 +255,7 @@ async def get_project(name: str):
246
255
  has_spec = _check_spec_exists(project_dir)
247
256
  stats = get_project_stats(project_dir)
248
257
  prompts_dir = _get_project_prompts_dir(project_dir)
258
+ ai_enabled, ai_interval = get_project_auto_improve(name)
249
259
 
250
260
  return ProjectDetail(
251
261
  name=name,
@@ -254,6 +264,8 @@ async def get_project(name: str):
254
264
  stats=stats,
255
265
  prompts_dir=str(prompts_dir),
256
266
  default_concurrency=get_project_concurrency(name),
267
+ auto_improve_enabled=ai_enabled,
268
+ auto_improve_interval_minutes=ai_interval,
257
269
  )
258
270
 
259
271
 
@@ -267,7 +279,7 @@ async def delete_project(name: str, delete_files: bool = False):
267
279
  delete_files: If True, also delete the project directory and files
268
280
  """
269
281
  _init_imports()
270
- (_, unregister_project, get_project_path, _, _, _, _) = _get_registry_functions()
282
+ (_, unregister_project, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
271
283
 
272
284
  name = validate_project_name(name)
273
285
  project_dir = get_project_path(name)
@@ -304,7 +316,7 @@ async def get_project_prompts(name: str):
304
316
  """Get the content of project prompt files."""
305
317
  _init_imports()
306
318
  assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
307
- (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
319
+ (_, _, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
308
320
 
309
321
  name = validate_project_name(name)
310
322
  project_dir = get_project_path(name)
@@ -338,7 +350,7 @@ async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
338
350
  """Update project prompt files."""
339
351
  _init_imports()
340
352
  assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
341
- (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
353
+ (_, _, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
342
354
 
343
355
  name = validate_project_name(name)
344
356
  project_dir = get_project_path(name)
@@ -368,7 +380,7 @@ async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
368
380
  async def get_project_stats_endpoint(name: str):
369
381
  """Get current progress statistics for a project."""
370
382
  _init_imports()
371
- (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
383
+ (_, _, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
372
384
 
373
385
  name = validate_project_name(name)
374
386
  project_dir = get_project_path(name)
@@ -395,7 +407,7 @@ async def reset_project(name: str, full_reset: bool = False):
395
407
  Dictionary with list of deleted files and reset type
396
408
  """
397
409
  _init_imports()
398
- (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
410
+ (_, _, get_project_path, _, _, _, _, _, _) = _get_registry_functions()
399
411
 
400
412
  name = validate_project_name(name)
401
413
  project_dir = get_project_path(name)
@@ -487,12 +499,13 @@ async def reset_project(name: str, full_reset: bool = False):
487
499
 
488
500
  @router.patch("/{name}/settings", response_model=ProjectDetail)
489
501
  async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
490
- """Update project-level settings (concurrency, etc.)."""
502
+ """Update project-level settings (concurrency, auto-improve, etc.)."""
491
503
  _init_imports()
492
504
  assert _check_spec_exists is not None # guaranteed by _init_imports()
493
505
  assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
494
506
  (_, _, get_project_path, _, _, get_project_concurrency,
495
- set_project_concurrency) = _get_registry_functions()
507
+ set_project_concurrency, get_project_auto_improve,
508
+ set_project_auto_improve) = _get_registry_functions()
496
509
 
497
510
  name = validate_project_name(name)
498
511
  project_dir = get_project_path(name)
@@ -509,10 +522,43 @@ async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
509
522
  if not success:
510
523
  raise HTTPException(status_code=500, detail="Failed to update concurrency")
511
524
 
525
+ # Update auto-improve config if either field was provided
526
+ auto_improve_touched = (
527
+ settings.auto_improve_enabled is not None
528
+ or settings.auto_improve_interval_minutes is not None
529
+ )
530
+ if auto_improve_touched:
531
+ try:
532
+ success = set_project_auto_improve(
533
+ name,
534
+ enabled=settings.auto_improve_enabled,
535
+ interval_minutes=settings.auto_improve_interval_minutes,
536
+ )
537
+ except ValueError as e:
538
+ raise HTTPException(status_code=400, detail=str(e))
539
+ if not success:
540
+ raise HTTPException(status_code=500, detail="Failed to update auto-improve settings")
541
+
542
+ # Sync the scheduler with the new state.
543
+ from ..services.scheduler_service import get_scheduler
544
+ scheduler = get_scheduler()
545
+ ai_enabled, ai_interval = get_project_auto_improve(name)
546
+ if ai_enabled:
547
+ try:
548
+ await scheduler.register_auto_improve(name, project_dir, ai_interval)
549
+ except Exception as e:
550
+ raise HTTPException(
551
+ status_code=500,
552
+ detail=f"Failed to register auto-improve schedule: {e}",
553
+ )
554
+ else:
555
+ scheduler.remove_auto_improve(name)
556
+
512
557
  # Return updated project details
513
558
  has_spec = _check_spec_exists(project_dir)
514
559
  stats = get_project_stats(project_dir)
515
560
  prompts_dir = _get_project_prompts_dir(project_dir)
561
+ ai_enabled, ai_interval = get_project_auto_improve(name)
516
562
 
517
563
  return ProjectDetail(
518
564
  name=name,
@@ -521,4 +567,6 @@ async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
521
567
  stats=stats,
522
568
  prompts_dir=str(prompts_dir),
523
569
  default_concurrency=get_project_concurrency(name),
570
+ auto_improve_enabled=ai_enabled,
571
+ auto_improve_interval_minutes=ai_interval,
524
572
  )
@@ -26,6 +26,7 @@ from registry import (
26
26
  AVAILABLE_MODELS,
27
27
  DEFAULT_MODEL,
28
28
  get_all_settings,
29
+ get_effort_setting,
29
30
  get_setting,
30
31
  set_setting,
31
32
  )
@@ -95,6 +96,8 @@ def _parse_bool(value: str | None, default: bool = False) -> bool:
95
96
  return value.lower() == "true"
96
97
 
97
98
 
99
+
100
+
98
101
  @router.get("", response_model=SettingsResponse)
99
102
  async def get_settings():
100
103
  """Get current global settings."""
@@ -114,6 +117,7 @@ async def get_settings():
114
117
  playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
115
118
  batch_size=_parse_int(all_settings.get("batch_size"), 3),
116
119
  testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
120
+ effort=get_effort_setting(),
117
121
  api_provider=api_provider,
118
122
  api_base_url=all_settings.get("api_base_url"),
119
123
  api_has_auth_token=bool(all_settings.get("api_auth_token")),
@@ -142,6 +146,9 @@ async def update_settings(update: SettingsUpdate):
142
146
  if update.testing_batch_size is not None:
143
147
  set_setting("testing_batch_size", str(update.testing_batch_size))
144
148
 
149
+ if update.effort is not None:
150
+ set_setting("effort", update.effort)
151
+
145
152
  # API provider settings
146
153
  if update.api_provider is not None:
147
154
  old_provider = get_setting("api_provider", "claude")
@@ -182,6 +189,7 @@ async def update_settings(update: SettingsUpdate):
182
189
  playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
183
190
  batch_size=_parse_int(all_settings.get("batch_size"), 3),
184
191
  testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
192
+ effort=get_effort_setting(),
185
193
  api_provider=api_provider,
186
194
  api_base_url=all_settings.get("api_base_url"),
187
195
  api_has_auth_token=bool(all_settings.get("api_auth_token")),
package/server/schemas.py CHANGED
@@ -18,7 +18,7 @@ _root = Path(__file__).parent.parent
18
18
  if str(_root) not in sys.path:
19
19
  sys.path.insert(0, str(_root))
20
20
 
21
- from registry import DEFAULT_MODEL, VALID_MODELS
21
+ from registry import DEFAULT_MODEL, LEGACY_MODEL_MAP, VALID_MODELS
22
22
 
23
23
  # ============================================================================
24
24
  # Project Schemas
@@ -46,6 +46,8 @@ class ProjectSummary(BaseModel):
46
46
  has_spec: bool
47
47
  stats: ProjectStats
48
48
  default_concurrency: int = 3
49
+ auto_improve_enabled: bool = False
50
+ auto_improve_interval_minutes: int = 10
49
51
 
50
52
 
51
53
  class ProjectDetail(BaseModel):
@@ -56,6 +58,8 @@ class ProjectDetail(BaseModel):
56
58
  stats: ProjectStats
57
59
  prompts_dir: str
58
60
  default_concurrency: int = 3
61
+ auto_improve_enabled: bool = False
62
+ auto_improve_interval_minutes: int = 10
59
63
 
60
64
 
61
65
  class ProjectPrompts(BaseModel):
@@ -75,6 +79,8 @@ class ProjectPromptsUpdate(BaseModel):
75
79
  class ProjectSettingsUpdate(BaseModel):
76
80
  """Request schema for updating project-level settings."""
77
81
  default_concurrency: int | None = None
82
+ auto_improve_enabled: bool | None = None
83
+ auto_improve_interval_minutes: int | None = None
78
84
 
79
85
  @field_validator('default_concurrency')
80
86
  @classmethod
@@ -83,6 +89,13 @@ class ProjectSettingsUpdate(BaseModel):
83
89
  raise ValueError("default_concurrency must be between 1 and 5")
84
90
  return v
85
91
 
92
+ @field_validator('auto_improve_interval_minutes')
93
+ @classmethod
94
+ def validate_auto_improve_interval(cls, v: int | None) -> int | None:
95
+ if v is not None and (v < 1 or v > 1440):
96
+ raise ValueError("auto_improve_interval_minutes must be between 1 and 1440")
97
+ return v
98
+
86
99
 
87
100
  # ============================================================================
88
101
  # Feature Schemas
@@ -471,6 +484,7 @@ class SettingsResponse(BaseModel):
471
484
  playwright_headless: bool = True
472
485
  batch_size: int = 3 # Features per coding agent batch (1-15)
473
486
  testing_batch_size: int = 3 # Features per testing agent batch (1-15)
487
+ effort: Literal["low", "medium", "high", "xhigh", "max"] = "xhigh"
474
488
  api_provider: str = "claude"
475
489
  api_base_url: str | None = None
476
490
  api_has_auth_token: bool = False # Never expose actual token
@@ -491,6 +505,7 @@ class SettingsUpdate(BaseModel):
491
505
  playwright_headless: bool | None = None
492
506
  batch_size: int | None = None # Features per agent batch (1-15)
493
507
  testing_batch_size: int | None = None # Features per testing agent batch (1-15)
508
+ effort: Literal["low", "medium", "high", "xhigh", "max"] | None = None
494
509
  api_provider: str | None = None
495
510
  api_base_url: str | None = Field(None, max_length=500)
496
511
  api_auth_token: str | None = Field(None, max_length=500) # Write-only, never returned
@@ -507,12 +522,16 @@ class SettingsUpdate(BaseModel):
507
522
 
508
523
  @field_validator('model')
509
524
  @classmethod
510
- def validate_model(cls, v: str | None, info) -> str | None: # type: ignore[override]
525
+ def validate_model(cls, v: str | None, info) -> str | None:
511
526
  if v is not None:
512
527
  # Skip VALID_MODELS check when using an alternative API provider
513
528
  api_provider = info.data.get("api_provider")
514
529
  if api_provider and api_provider != "claude":
515
530
  return v
531
+ # Transparently accept legacy IDs so in-flight clients don't 422
532
+ # during an upgrade window; LEGACY_MODEL_MAP already covers migration.
533
+ if v in LEGACY_MODEL_MAP:
534
+ v = LEGACY_MODEL_MAP[v]
516
535
  if v not in VALID_MODELS:
517
536
  raise ValueError(f"Invalid model. Must be one of: {VALID_MODELS}")
518
537
  return v
@@ -270,8 +270,9 @@ class AssistantChatSession:
270
270
  system_cli = shutil.which("claude")
271
271
 
272
272
  # Build environment overrides for API configuration
273
- from registry import DEFAULT_MODEL, get_effective_sdk_env
273
+ from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
274
274
  sdk_env = get_effective_sdk_env()
275
+ effort = get_effort_setting()
275
276
 
276
277
  # Determine model from SDK env (provider-aware) or fallback to env/default
277
278
  model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
@@ -281,6 +282,7 @@ class AssistantChatSession:
281
282
  self.client = ClaudeSDKClient(
282
283
  options=ClaudeAgentOptions(
283
284
  model=model,
285
+ effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
284
286
  cli_path=system_cli,
285
287
  # System prompt loaded from CLAUDE.md via setting_sources
286
288
  # This avoids Windows command line length limit (~8191 chars)
@@ -161,8 +161,9 @@ class ExpandChatSession:
161
161
  system_prompt = skill_content.replace("$ARGUMENTS", project_path)
162
162
 
163
163
  # Build environment overrides for API configuration
164
- from registry import DEFAULT_MODEL, get_effective_sdk_env
164
+ from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
165
165
  sdk_env = get_effective_sdk_env()
166
+ effort = get_effort_setting()
166
167
 
167
168
  # Determine model from SDK env (provider-aware) or fallback to env/default
168
169
  model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
@@ -184,6 +185,7 @@ class ExpandChatSession:
184
185
  self.client = ClaudeSDKClient(
185
186
  options=ClaudeAgentOptions(
186
187
  model=model,
188
+ effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
187
189
  cli_path=system_cli,
188
190
  system_prompt=system_prompt,
189
191
  allowed_tools=[
@@ -85,6 +85,7 @@ class AgentProcessManager:
85
85
  self.parallel_mode: bool = False # Parallel execution mode
86
86
  self.max_concurrency: int | None = None # Max concurrent agents
87
87
  self.testing_agent_ratio: int = 1 # Regression testing agents (0-3)
88
+ self.auto_improve: bool = False # Auto-improve mode (single session)
88
89
 
89
90
  # Support multiple callbacks (for multiple WebSocket clients)
90
91
  self._output_callbacks: Set[Callable[[str], Awaitable[None]]] = set()
@@ -375,6 +376,7 @@ class AgentProcessManager:
375
376
  playwright_headless: bool = True,
376
377
  batch_size: int = 3,
377
378
  testing_batch_size: int = 3,
379
+ auto_improve: bool = False,
378
380
  ) -> tuple[bool, str]:
379
381
  """
380
382
  Start the agent as a subprocess.
@@ -386,6 +388,10 @@ class AgentProcessManager:
386
388
  max_concurrency: Max concurrent coding agents (1-5, default 1)
387
389
  testing_agent_ratio: Number of regression testing agents (0-3, default 1)
388
390
  playwright_headless: If True, run browser in headless mode
391
+ auto_improve: If True, run in auto-improve mode. Forces single-agent
392
+ execution (concurrency=1, testing_ratio=0) and passes
393
+ --auto-improve to the subprocess so it runs exactly one
394
+ improvement session and exits.
389
395
 
390
396
  Returns:
391
397
  Tuple of (success, message)
@@ -408,12 +414,19 @@ class AgentProcessManager:
408
414
  # Clean up features stuck from a previous crash/stop
409
415
  self._cleanup_stale_features()
410
416
 
417
+ # Auto-improve mode forces single-agent execution and skips testing
418
+ # agents — the subprocess bypasses the orchestrator entirely.
419
+ if auto_improve:
420
+ max_concurrency = 1
421
+ testing_agent_ratio = 0
422
+
411
423
  # Store for status queries
412
424
  self.yolo_mode = yolo_mode
413
425
  self.model = model
414
426
  self.parallel_mode = True # Always True now (unified orchestrator)
415
427
  self.max_concurrency = max_concurrency or 1
416
428
  self.testing_agent_ratio = testing_agent_ratio
429
+ self.auto_improve = auto_improve
417
430
 
418
431
  # Build command - unified orchestrator with --concurrency
419
432
  cmd = [
@@ -432,6 +445,10 @@ class AgentProcessManager:
432
445
  if yolo_mode:
433
446
  cmd.append("--yolo")
434
447
 
448
+ # Add --auto-improve flag: bypasses the orchestrator for a one-shot run
449
+ if auto_improve:
450
+ cmd.append("--auto-improve")
451
+
435
452
  # Add --concurrency flag (unified orchestrator always uses this)
436
453
  cmd.extend(["--concurrency", str(max_concurrency or 1)])
437
454
 
@@ -15,6 +15,7 @@ from typing import Optional
15
15
 
16
16
  from apscheduler.schedulers.asyncio import AsyncIOScheduler
17
17
  from apscheduler.triggers.cron import CronTrigger
18
+ from apscheduler.triggers.interval import IntervalTrigger
18
19
 
19
20
  # Add parent directory for imports
20
21
  sys.path.insert(0, str(Path(__file__).parent.parent.parent))
@@ -73,19 +74,39 @@ class SchedulerService:
73
74
  logger.info("Scheduler service stopped")
74
75
 
75
76
  async def _load_all_schedules(self):
76
- """Load schedules for all registered projects."""
77
+ """Load schedules and auto-improve jobs for all registered projects."""
77
78
  from registry import list_registered_projects
78
79
 
79
80
  try:
80
81
  projects = list_registered_projects()
81
82
  total_loaded = 0
83
+ total_auto_improve = 0
82
84
  for project_name, info in projects.items():
83
85
  project_path = Path(info.get("path", ""))
84
- if project_path.exists():
85
- count = await self._load_project_schedules(project_name, project_path)
86
- total_loaded += count
86
+ if not project_path.exists():
87
+ continue
88
+
89
+ # Windowed schedules (cron-based)
90
+ count = await self._load_project_schedules(project_name, project_path)
91
+ total_loaded += count
92
+
93
+ # Auto-improve interval jobs (stored in the registry directly)
94
+ if info.get("auto_improve_enabled"):
95
+ interval = int(info.get("auto_improve_interval_minutes", 10) or 10)
96
+ try:
97
+ await self.register_auto_improve(project_name, project_path, interval)
98
+ total_auto_improve += 1
99
+ except Exception as e:
100
+ logger.error(
101
+ f"Failed to register auto-improve for {project_name}: {e}"
102
+ )
103
+
87
104
  if total_loaded > 0:
88
105
  logger.info(f"Loaded {total_loaded} schedule(s) across all projects")
106
+ if total_auto_improve > 0:
107
+ logger.info(
108
+ f"Registered {total_auto_improve} auto-improve job(s) across all projects"
109
+ )
89
110
  except Exception as e:
90
111
  logger.error(f"Error loading schedules: {e}")
91
112
 
@@ -205,6 +226,135 @@ class SchedulerService:
205
226
  else:
206
227
  logger.warning(f"No jobs found to remove for schedule {schedule_id}")
207
228
 
229
+ # ------------------------------------------------------------------
230
+ # Auto-improve interval jobs
231
+ # ------------------------------------------------------------------
232
+
233
+ @staticmethod
234
+ def _auto_improve_job_id(project_name: str) -> str:
235
+ return f"auto_improve_{project_name}"
236
+
237
+ async def register_auto_improve(
238
+ self,
239
+ project_name: str,
240
+ project_dir: Path,
241
+ interval_minutes: int,
242
+ ):
243
+ """Register or replace the auto-improve interval job for a project.
244
+
245
+ Uses APScheduler IntervalTrigger to fire every ``interval_minutes``.
246
+ On each tick, a single one-shot auto-improve agent session is started
247
+ (unless the project's agent is already running, in which case the tick
248
+ is silently skipped).
249
+ """
250
+ if interval_minutes < 1 or interval_minutes > 1440:
251
+ raise ValueError("interval_minutes must be between 1 and 1440")
252
+
253
+ job_id = self._auto_improve_job_id(project_name)
254
+ trigger = IntervalTrigger(minutes=interval_minutes)
255
+
256
+ self.scheduler.add_job(
257
+ self._handle_auto_improve_tick,
258
+ trigger,
259
+ id=job_id,
260
+ args=[project_name, str(project_dir)],
261
+ replace_existing=True,
262
+ misfire_grace_time=300,
263
+ max_instances=1, # Never overlap ticks
264
+ coalesce=True, # Collapse missed ticks during long runs
265
+ )
266
+
267
+ job = self.scheduler.get_job(job_id)
268
+ next_run = job.next_run_time if job else None
269
+ logger.info(
270
+ f"Registered auto-improve for {project_name}: "
271
+ f"every {interval_minutes} min (next: {next_run})"
272
+ )
273
+
274
+ def remove_auto_improve(self, project_name: str):
275
+ """Remove the auto-improve interval job for a project (no-op if missing)."""
276
+ job_id = self._auto_improve_job_id(project_name)
277
+ try:
278
+ self.scheduler.remove_job(job_id)
279
+ logger.info(f"Removed auto-improve job for {project_name}")
280
+ except Exception:
281
+ logger.debug(f"No auto-improve job to remove for {project_name}")
282
+
283
+ async def _handle_auto_improve_tick(
284
+ self,
285
+ project_name: str,
286
+ project_dir_str: str,
287
+ ):
288
+ """Fire one auto-improve agent session for a project.
289
+
290
+ Silently skips if the agent is already running (manual run, another
291
+ tick still executing, etc.). The scheduler's ``max_instances=1`` and
292
+ ``coalesce=True`` settings make sure ticks never stack up.
293
+ """
294
+ logger.info(f"Auto-improve tick for {project_name}")
295
+ project_dir = Path(project_dir_str)
296
+
297
+ if not project_dir.exists():
298
+ logger.warning(
299
+ f"Auto-improve tick: project dir missing for {project_name}, skipping"
300
+ )
301
+ return
302
+
303
+ try:
304
+ from .process_manager import get_manager
305
+
306
+ root_dir = Path(__file__).parent.parent.parent
307
+ manager = get_manager(project_name, project_dir, root_dir)
308
+
309
+ if manager.status in ("running", "paused", "pausing", "paused_graceful"):
310
+ logger.info(
311
+ f"Auto-improve tick for {project_name}: agent already "
312
+ f"{manager.status}, skipping this tick"
313
+ )
314
+ return
315
+
316
+ # Resolve effective yolo/model from global settings, mirroring the
317
+ # agent router's _get_settings_defaults() pattern so auto-improve
318
+ # respects whatever the user has configured globally.
319
+ yolo_mode, model = self._resolve_agent_defaults()
320
+
321
+ logger.info(
322
+ f"Starting auto-improve agent for {project_name} "
323
+ f"(yolo={yolo_mode}, model={model})"
324
+ )
325
+ success, msg = await manager.start(
326
+ yolo_mode=yolo_mode,
327
+ model=model,
328
+ max_concurrency=1,
329
+ testing_agent_ratio=0,
330
+ playwright_headless=True,
331
+ auto_improve=True,
332
+ )
333
+
334
+ if success:
335
+ logger.info(f"Auto-improve agent started for {project_name}")
336
+ else:
337
+ logger.warning(
338
+ f"Auto-improve agent failed to start for {project_name}: {msg}"
339
+ )
340
+ except Exception as e:
341
+ logger.error(f"Error in auto-improve tick for {project_name}: {e}")
342
+
343
+ @staticmethod
344
+ def _resolve_agent_defaults() -> tuple[bool, str]:
345
+ """Resolve (yolo_mode, model) from global settings.
346
+
347
+ Kept separate from the agent router's helper so the scheduler never
348
+ has to import FastAPI routers. Mirrors the parsing behavior of
349
+ ``server/routers/agent.py::_get_settings_defaults``.
350
+ """
351
+ from registry import DEFAULT_MODEL, get_all_settings
352
+
353
+ settings = get_all_settings()
354
+ yolo_mode = (settings.get("yolo_mode") or "false").lower() == "true"
355
+ model = settings.get("api_model") or settings.get("model", DEFAULT_MODEL)
356
+ return yolo_mode, model
357
+
208
358
  async def _handle_scheduled_start(
209
359
  self, project_name: str, schedule_id: int, project_dir_str: str
210
360
  ):
@@ -147,8 +147,9 @@ class SpecChatSession:
147
147
  system_cli = shutil.which("claude")
148
148
 
149
149
  # Build environment overrides for API configuration
150
- from registry import DEFAULT_MODEL, get_effective_sdk_env
150
+ from registry import DEFAULT_MODEL, get_effective_sdk_env, get_effort_setting
151
151
  sdk_env = get_effective_sdk_env()
152
+ effort = get_effort_setting()
152
153
 
153
154
  # Determine model from SDK env (provider-aware) or fallback to env/default
154
155
  model = sdk_env.get("ANTHROPIC_DEFAULT_OPUS_MODEL") or os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", DEFAULT_MODEL)
@@ -157,6 +158,7 @@ class SpecChatSession:
157
158
  self.client = ClaudeSDKClient(
158
159
  options=ClaudeAgentOptions(
159
160
  model=model,
161
+ effort=effort, # type: ignore[arg-type] # SDK 0.1.61 Literal omits "xhigh"
160
162
  cli_path=system_cli,
161
163
  # System prompt loaded from CLAUDE.md via setting_sources
162
164
  # Include "user" for global skills and subagents from ~/.claude/