autoforge-ai 0.1.20 → 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.
- package/.claude/templates/auto_improve_prompt.template.md +160 -0
- package/agent.py +14 -2
- package/autonomous_agent_demo.py +27 -1
- package/client.py +7 -2
- package/package.json +1 -1
- package/prompts.py +24 -0
- package/registry.py +143 -15
- package/server/routers/projects.py +58 -10
- package/server/routers/settings.py +8 -0
- package/server/schemas.py +21 -2
- package/server/services/assistant_chat_session.py +3 -1
- package/server/services/browser_view_service.py +280 -280
- package/server/services/expand_chat_session.py +3 -1
- package/server/services/process_manager.py +17 -0
- package/server/services/scheduler_service.py +154 -4
- package/server/services/spec_chat_session.py +3 -1
- package/ui/dist/assets/index-BvNxzjlP.js +96 -0
- package/ui/dist/assets/index-hSFqqmJF.css +1 -0
- package/ui/dist/assets/{vendor-flow-CSXy01ye.js → vendor-flow-4rkFkfFX.js} +1 -1
- package/ui/dist/assets/vendor-radix-CXoPacKb.js +45 -0
- package/ui/dist/assets/vendor-utils-D_WdX4_S.js +2 -0
- package/ui/dist/index.html +5 -5
- package/ui/dist/assets/index-B5-x5jW8.js +0 -96
- package/ui/dist/assets/index-DkJ1zszK.css +0 -1
- package/ui/dist/assets/vendor-radix-DIVIznMB.js +0 -45
- package/ui/dist/assets/vendor-utils-CnTXttNm.js +0 -2
|
@@ -1,280 +1,280 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Browser View Service
|
|
3
|
-
====================
|
|
4
|
-
|
|
5
|
-
Captures periodic screenshots from active playwright-cli browser sessions
|
|
6
|
-
and streams them to the UI via WebSocket callbacks.
|
|
7
|
-
|
|
8
|
-
Each agent gets an isolated browser session (e.g., coding-5, testing-0).
|
|
9
|
-
This service polls those sessions with `playwright-cli screenshot` and
|
|
10
|
-
delivers the frames to subscribed UI clients.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
import asyncio
|
|
14
|
-
import base64
|
|
15
|
-
import logging
|
|
16
|
-
import shutil
|
|
17
|
-
import threading
|
|
18
|
-
from dataclasses import dataclass
|
|
19
|
-
from datetime import datetime
|
|
20
|
-
from pathlib import Path
|
|
21
|
-
from typing import Awaitable, Callable
|
|
22
|
-
|
|
23
|
-
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
25
|
-
POLL_INTERVAL = 2.0 # seconds between screenshot captures
|
|
26
|
-
BACKOFF_INTERVAL = 10.0 # seconds after repeated failures
|
|
27
|
-
MAX_FAILURES_BEFORE_BACKOFF = 10
|
|
28
|
-
MAX_FAILURES_BEFORE_STOP = 90 # ~3 minutes at normal rate before giving up
|
|
29
|
-
SCREENSHOT_TIMEOUT = 5 # seconds
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
@dataclass
|
|
33
|
-
class SessionInfo:
|
|
34
|
-
"""Metadata for an active browser session."""
|
|
35
|
-
session_name: str
|
|
36
|
-
agent_index: int
|
|
37
|
-
agent_type: str # "coding" or "testing"
|
|
38
|
-
feature_id: int
|
|
39
|
-
feature_name: str
|
|
40
|
-
consecutive_failures: int = 0
|
|
41
|
-
stopped: bool = False
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@dataclass
|
|
45
|
-
class ScreenshotData:
|
|
46
|
-
"""A captured screenshot ready for delivery."""
|
|
47
|
-
session_name: str
|
|
48
|
-
agent_index: int
|
|
49
|
-
agent_type: str
|
|
50
|
-
feature_id: int
|
|
51
|
-
feature_name: str
|
|
52
|
-
image_base64: str # base64-encoded PNG
|
|
53
|
-
timestamp: str
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
class BrowserViewService:
|
|
57
|
-
"""Manages screenshot capture for active agent browser sessions.
|
|
58
|
-
|
|
59
|
-
Follows the same singleton-per-project pattern as DevServerProcessManager.
|
|
60
|
-
"""
|
|
61
|
-
|
|
62
|
-
def __init__(self, project_name: str, project_dir: Path):
|
|
63
|
-
self.project_name = project_name
|
|
64
|
-
self.project_dir = project_dir
|
|
65
|
-
self._active_sessions: dict[str, SessionInfo] = {}
|
|
66
|
-
self._subscribers = 0
|
|
67
|
-
self._poll_task: asyncio.Task | None = None
|
|
68
|
-
self._screenshot_callbacks: set[Callable[[ScreenshotData], Awaitable[None]]] = set()
|
|
69
|
-
self._lock = asyncio.Lock()
|
|
70
|
-
self._playwright_cli: str | None = None
|
|
71
|
-
|
|
72
|
-
def _get_playwright_cli(self) -> str | None:
|
|
73
|
-
"""Find playwright-cli executable."""
|
|
74
|
-
if self._playwright_cli is not None:
|
|
75
|
-
return self._playwright_cli
|
|
76
|
-
path = shutil.which("playwright-cli")
|
|
77
|
-
if path:
|
|
78
|
-
self._playwright_cli = path
|
|
79
|
-
else:
|
|
80
|
-
logger.warning("playwright-cli not found in PATH; browser view disabled")
|
|
81
|
-
return self._playwright_cli
|
|
82
|
-
|
|
83
|
-
async def register_session(
|
|
84
|
-
self,
|
|
85
|
-
session_name: str,
|
|
86
|
-
agent_index: int,
|
|
87
|
-
agent_type: str,
|
|
88
|
-
feature_id: int,
|
|
89
|
-
feature_name: str,
|
|
90
|
-
) -> None:
|
|
91
|
-
"""Register an agent's browser session for screenshot capture."""
|
|
92
|
-
async with self._lock:
|
|
93
|
-
self._active_sessions[session_name] = SessionInfo(
|
|
94
|
-
session_name=session_name,
|
|
95
|
-
agent_index=agent_index,
|
|
96
|
-
agent_type=agent_type,
|
|
97
|
-
feature_id=feature_id,
|
|
98
|
-
feature_name=feature_name,
|
|
99
|
-
)
|
|
100
|
-
logger.debug("Registered browser session: %s", session_name)
|
|
101
|
-
|
|
102
|
-
async def unregister_session(self, session_name: str) -> None:
|
|
103
|
-
"""Unregister a browser session when agent completes."""
|
|
104
|
-
async with self._lock:
|
|
105
|
-
removed = self._active_sessions.pop(session_name, None)
|
|
106
|
-
if removed:
|
|
107
|
-
logger.debug("Unregistered browser session: %s", session_name)
|
|
108
|
-
# Clean up screenshot file
|
|
109
|
-
self._cleanup_screenshot_file(session_name)
|
|
110
|
-
|
|
111
|
-
def add_screenshot_callback(self, callback: Callable[[ScreenshotData], Awaitable[None]]) -> None:
|
|
112
|
-
self._screenshot_callbacks.add(callback)
|
|
113
|
-
|
|
114
|
-
def remove_screenshot_callback(self, callback: Callable[[ScreenshotData], Awaitable[None]]) -> None:
|
|
115
|
-
self._screenshot_callbacks.discard(callback)
|
|
116
|
-
|
|
117
|
-
async def add_subscriber(self) -> None:
|
|
118
|
-
"""Called when a UI client wants browser screenshots."""
|
|
119
|
-
async with self._lock:
|
|
120
|
-
self._subscribers += 1
|
|
121
|
-
if self._subscribers == 1:
|
|
122
|
-
self._start_polling()
|
|
123
|
-
|
|
124
|
-
async def remove_subscriber(self) -> None:
|
|
125
|
-
"""Called when a UI client stops wanting screenshots."""
|
|
126
|
-
async with self._lock:
|
|
127
|
-
self._subscribers = max(0, self._subscribers - 1)
|
|
128
|
-
if self._subscribers == 0:
|
|
129
|
-
self._stop_polling()
|
|
130
|
-
|
|
131
|
-
async def stop(self) -> None:
|
|
132
|
-
"""Clean up all sessions and stop polling."""
|
|
133
|
-
async with self._lock:
|
|
134
|
-
for session_name in list(self._active_sessions):
|
|
135
|
-
self._cleanup_screenshot_file(session_name)
|
|
136
|
-
self._active_sessions.clear()
|
|
137
|
-
self._stop_polling()
|
|
138
|
-
|
|
139
|
-
def _start_polling(self) -> None:
|
|
140
|
-
"""Start the screenshot polling loop."""
|
|
141
|
-
if self._poll_task is not None and not self._poll_task.done():
|
|
142
|
-
return
|
|
143
|
-
self._poll_task = asyncio.create_task(self._poll_loop())
|
|
144
|
-
logger.info("Started browser screenshot polling for %s", self.project_name)
|
|
145
|
-
|
|
146
|
-
def _stop_polling(self) -> None:
|
|
147
|
-
"""Stop the screenshot polling loop."""
|
|
148
|
-
if self._poll_task is not None and not self._poll_task.done():
|
|
149
|
-
self._poll_task.cancel()
|
|
150
|
-
self._poll_task = None
|
|
151
|
-
logger.info("Stopped browser screenshot polling for %s", self.project_name)
|
|
152
|
-
|
|
153
|
-
async def _poll_loop(self) -> None:
|
|
154
|
-
"""Main polling loop - capture screenshots for all active sessions."""
|
|
155
|
-
try:
|
|
156
|
-
while True:
|
|
157
|
-
async with self._lock:
|
|
158
|
-
sessions = list(self._active_sessions.values())
|
|
159
|
-
|
|
160
|
-
if sessions and self._screenshot_callbacks:
|
|
161
|
-
# Capture screenshots with limited concurrency
|
|
162
|
-
sem = asyncio.Semaphore(3)
|
|
163
|
-
|
|
164
|
-
async def capture_with_sem(session: SessionInfo) -> None:
|
|
165
|
-
async with sem:
|
|
166
|
-
await self._capture_and_deliver(session)
|
|
167
|
-
|
|
168
|
-
await asyncio.gather(
|
|
169
|
-
*(capture_with_sem(s) for s in sessions if not s.stopped),
|
|
170
|
-
return_exceptions=True,
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
await asyncio.sleep(POLL_INTERVAL)
|
|
174
|
-
except asyncio.CancelledError:
|
|
175
|
-
pass
|
|
176
|
-
except Exception:
|
|
177
|
-
logger.warning("Browser screenshot polling crashed", exc_info=True)
|
|
178
|
-
|
|
179
|
-
async def _capture_and_deliver(self, session: SessionInfo) -> None:
|
|
180
|
-
"""Capture a screenshot for a session and deliver to callbacks."""
|
|
181
|
-
cli = self._get_playwright_cli()
|
|
182
|
-
if not cli:
|
|
183
|
-
return
|
|
184
|
-
|
|
185
|
-
# Determine interval based on failure count
|
|
186
|
-
if session.consecutive_failures >= MAX_FAILURES_BEFORE_BACKOFF:
|
|
187
|
-
# In backoff mode - only capture every BACKOFF_INTERVAL/POLL_INTERVAL polls
|
|
188
|
-
# We achieve this by checking a simple modulo on failure count
|
|
189
|
-
if session.consecutive_failures % int(BACKOFF_INTERVAL / POLL_INTERVAL) != 0:
|
|
190
|
-
return
|
|
191
|
-
|
|
192
|
-
screenshot_dir = self.project_dir / ".playwright-cli"
|
|
193
|
-
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
194
|
-
screenshot_path = screenshot_dir / f"_view_{session.session_name}.png"
|
|
195
|
-
|
|
196
|
-
try:
|
|
197
|
-
proc = await asyncio.create_subprocess_exec(
|
|
198
|
-
cli, "-s", session.session_name, "screenshot",
|
|
199
|
-
f"--filename={screenshot_path}",
|
|
200
|
-
stdout=asyncio.subprocess.PIPE,
|
|
201
|
-
stderr=asyncio.subprocess.PIPE,
|
|
202
|
-
cwd=str(self.project_dir),
|
|
203
|
-
)
|
|
204
|
-
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=SCREENSHOT_TIMEOUT)
|
|
205
|
-
|
|
206
|
-
if proc.returncode != 0:
|
|
207
|
-
session.consecutive_failures += 1
|
|
208
|
-
if session.consecutive_failures >= MAX_FAILURES_BEFORE_STOP:
|
|
209
|
-
session.stopped = True
|
|
210
|
-
logger.debug(
|
|
211
|
-
"Stopped polling session %s after %d failures",
|
|
212
|
-
session.session_name, session.consecutive_failures,
|
|
213
|
-
)
|
|
214
|
-
return
|
|
215
|
-
|
|
216
|
-
# Read and encode the screenshot
|
|
217
|
-
if not screenshot_path.exists():
|
|
218
|
-
session.consecutive_failures += 1
|
|
219
|
-
return
|
|
220
|
-
|
|
221
|
-
image_bytes = screenshot_path.read_bytes()
|
|
222
|
-
image_base64 = base64.b64encode(image_bytes).decode("ascii")
|
|
223
|
-
|
|
224
|
-
# Reset failure counter on success
|
|
225
|
-
session.consecutive_failures = 0
|
|
226
|
-
# Re-enable if previously stopped
|
|
227
|
-
session.stopped = False
|
|
228
|
-
|
|
229
|
-
screenshot = ScreenshotData(
|
|
230
|
-
session_name=session.session_name,
|
|
231
|
-
agent_index=session.agent_index,
|
|
232
|
-
agent_type=session.agent_type,
|
|
233
|
-
feature_id=session.feature_id,
|
|
234
|
-
feature_name=session.feature_name,
|
|
235
|
-
image_base64=image_base64,
|
|
236
|
-
timestamp=datetime.now().isoformat(),
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
# Deliver to all callbacks
|
|
240
|
-
for callback in list(self._screenshot_callbacks):
|
|
241
|
-
try:
|
|
242
|
-
await callback(screenshot)
|
|
243
|
-
except Exception:
|
|
244
|
-
pass # Connection may be closed
|
|
245
|
-
|
|
246
|
-
except asyncio.TimeoutError:
|
|
247
|
-
session.consecutive_failures += 1
|
|
248
|
-
except Exception:
|
|
249
|
-
session.consecutive_failures += 1
|
|
250
|
-
finally:
|
|
251
|
-
# Clean up the screenshot file
|
|
252
|
-
try:
|
|
253
|
-
screenshot_path.unlink(missing_ok=True)
|
|
254
|
-
except Exception:
|
|
255
|
-
pass
|
|
256
|
-
|
|
257
|
-
def _cleanup_screenshot_file(self, session_name: str) -> None:
|
|
258
|
-
"""Remove a session's screenshot file."""
|
|
259
|
-
try:
|
|
260
|
-
path = self.project_dir / ".playwright-cli" / f"_view_{session_name}.png"
|
|
261
|
-
path.unlink(missing_ok=True)
|
|
262
|
-
except Exception:
|
|
263
|
-
pass
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
# ---------------------------------------------------------------------------
|
|
267
|
-
# Global instance management (thread-safe)
|
|
268
|
-
# ---------------------------------------------------------------------------
|
|
269
|
-
|
|
270
|
-
_services: dict[tuple[str, str], BrowserViewService] = {}
|
|
271
|
-
_services_lock = threading.Lock()
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def get_browser_view_service(project_name: str, project_dir: Path) -> BrowserViewService:
|
|
275
|
-
"""Get or create a BrowserViewService for a project (thread-safe)."""
|
|
276
|
-
with _services_lock:
|
|
277
|
-
key = (project_name, str(project_dir.resolve()))
|
|
278
|
-
if key not in _services:
|
|
279
|
-
_services[key] = BrowserViewService(project_name, project_dir)
|
|
280
|
-
return _services[key]
|
|
1
|
+
"""
|
|
2
|
+
Browser View Service
|
|
3
|
+
====================
|
|
4
|
+
|
|
5
|
+
Captures periodic screenshots from active playwright-cli browser sessions
|
|
6
|
+
and streams them to the UI via WebSocket callbacks.
|
|
7
|
+
|
|
8
|
+
Each agent gets an isolated browser session (e.g., coding-5, testing-0).
|
|
9
|
+
This service polls those sessions with `playwright-cli screenshot` and
|
|
10
|
+
delivers the frames to subscribed UI clients.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import base64
|
|
15
|
+
import logging
|
|
16
|
+
import shutil
|
|
17
|
+
import threading
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Awaitable, Callable
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
POLL_INTERVAL = 2.0 # seconds between screenshot captures
|
|
26
|
+
BACKOFF_INTERVAL = 10.0 # seconds after repeated failures
|
|
27
|
+
MAX_FAILURES_BEFORE_BACKOFF = 10
|
|
28
|
+
MAX_FAILURES_BEFORE_STOP = 90 # ~3 minutes at normal rate before giving up
|
|
29
|
+
SCREENSHOT_TIMEOUT = 5 # seconds
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class SessionInfo:
|
|
34
|
+
"""Metadata for an active browser session."""
|
|
35
|
+
session_name: str
|
|
36
|
+
agent_index: int
|
|
37
|
+
agent_type: str # "coding" or "testing"
|
|
38
|
+
feature_id: int
|
|
39
|
+
feature_name: str
|
|
40
|
+
consecutive_failures: int = 0
|
|
41
|
+
stopped: bool = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ScreenshotData:
|
|
46
|
+
"""A captured screenshot ready for delivery."""
|
|
47
|
+
session_name: str
|
|
48
|
+
agent_index: int
|
|
49
|
+
agent_type: str
|
|
50
|
+
feature_id: int
|
|
51
|
+
feature_name: str
|
|
52
|
+
image_base64: str # base64-encoded PNG
|
|
53
|
+
timestamp: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class BrowserViewService:
|
|
57
|
+
"""Manages screenshot capture for active agent browser sessions.
|
|
58
|
+
|
|
59
|
+
Follows the same singleton-per-project pattern as DevServerProcessManager.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, project_name: str, project_dir: Path):
|
|
63
|
+
self.project_name = project_name
|
|
64
|
+
self.project_dir = project_dir
|
|
65
|
+
self._active_sessions: dict[str, SessionInfo] = {}
|
|
66
|
+
self._subscribers = 0
|
|
67
|
+
self._poll_task: asyncio.Task | None = None
|
|
68
|
+
self._screenshot_callbacks: set[Callable[[ScreenshotData], Awaitable[None]]] = set()
|
|
69
|
+
self._lock = asyncio.Lock()
|
|
70
|
+
self._playwright_cli: str | None = None
|
|
71
|
+
|
|
72
|
+
def _get_playwright_cli(self) -> str | None:
|
|
73
|
+
"""Find playwright-cli executable."""
|
|
74
|
+
if self._playwright_cli is not None:
|
|
75
|
+
return self._playwright_cli
|
|
76
|
+
path = shutil.which("playwright-cli")
|
|
77
|
+
if path:
|
|
78
|
+
self._playwright_cli = path
|
|
79
|
+
else:
|
|
80
|
+
logger.warning("playwright-cli not found in PATH; browser view disabled")
|
|
81
|
+
return self._playwright_cli
|
|
82
|
+
|
|
83
|
+
async def register_session(
|
|
84
|
+
self,
|
|
85
|
+
session_name: str,
|
|
86
|
+
agent_index: int,
|
|
87
|
+
agent_type: str,
|
|
88
|
+
feature_id: int,
|
|
89
|
+
feature_name: str,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Register an agent's browser session for screenshot capture."""
|
|
92
|
+
async with self._lock:
|
|
93
|
+
self._active_sessions[session_name] = SessionInfo(
|
|
94
|
+
session_name=session_name,
|
|
95
|
+
agent_index=agent_index,
|
|
96
|
+
agent_type=agent_type,
|
|
97
|
+
feature_id=feature_id,
|
|
98
|
+
feature_name=feature_name,
|
|
99
|
+
)
|
|
100
|
+
logger.debug("Registered browser session: %s", session_name)
|
|
101
|
+
|
|
102
|
+
async def unregister_session(self, session_name: str) -> None:
|
|
103
|
+
"""Unregister a browser session when agent completes."""
|
|
104
|
+
async with self._lock:
|
|
105
|
+
removed = self._active_sessions.pop(session_name, None)
|
|
106
|
+
if removed:
|
|
107
|
+
logger.debug("Unregistered browser session: %s", session_name)
|
|
108
|
+
# Clean up screenshot file
|
|
109
|
+
self._cleanup_screenshot_file(session_name)
|
|
110
|
+
|
|
111
|
+
def add_screenshot_callback(self, callback: Callable[[ScreenshotData], Awaitable[None]]) -> None:
|
|
112
|
+
self._screenshot_callbacks.add(callback)
|
|
113
|
+
|
|
114
|
+
def remove_screenshot_callback(self, callback: Callable[[ScreenshotData], Awaitable[None]]) -> None:
|
|
115
|
+
self._screenshot_callbacks.discard(callback)
|
|
116
|
+
|
|
117
|
+
async def add_subscriber(self) -> None:
|
|
118
|
+
"""Called when a UI client wants browser screenshots."""
|
|
119
|
+
async with self._lock:
|
|
120
|
+
self._subscribers += 1
|
|
121
|
+
if self._subscribers == 1:
|
|
122
|
+
self._start_polling()
|
|
123
|
+
|
|
124
|
+
async def remove_subscriber(self) -> None:
|
|
125
|
+
"""Called when a UI client stops wanting screenshots."""
|
|
126
|
+
async with self._lock:
|
|
127
|
+
self._subscribers = max(0, self._subscribers - 1)
|
|
128
|
+
if self._subscribers == 0:
|
|
129
|
+
self._stop_polling()
|
|
130
|
+
|
|
131
|
+
async def stop(self) -> None:
|
|
132
|
+
"""Clean up all sessions and stop polling."""
|
|
133
|
+
async with self._lock:
|
|
134
|
+
for session_name in list(self._active_sessions):
|
|
135
|
+
self._cleanup_screenshot_file(session_name)
|
|
136
|
+
self._active_sessions.clear()
|
|
137
|
+
self._stop_polling()
|
|
138
|
+
|
|
139
|
+
def _start_polling(self) -> None:
|
|
140
|
+
"""Start the screenshot polling loop."""
|
|
141
|
+
if self._poll_task is not None and not self._poll_task.done():
|
|
142
|
+
return
|
|
143
|
+
self._poll_task = asyncio.create_task(self._poll_loop())
|
|
144
|
+
logger.info("Started browser screenshot polling for %s", self.project_name)
|
|
145
|
+
|
|
146
|
+
def _stop_polling(self) -> None:
|
|
147
|
+
"""Stop the screenshot polling loop."""
|
|
148
|
+
if self._poll_task is not None and not self._poll_task.done():
|
|
149
|
+
self._poll_task.cancel()
|
|
150
|
+
self._poll_task = None
|
|
151
|
+
logger.info("Stopped browser screenshot polling for %s", self.project_name)
|
|
152
|
+
|
|
153
|
+
async def _poll_loop(self) -> None:
|
|
154
|
+
"""Main polling loop - capture screenshots for all active sessions."""
|
|
155
|
+
try:
|
|
156
|
+
while True:
|
|
157
|
+
async with self._lock:
|
|
158
|
+
sessions = list(self._active_sessions.values())
|
|
159
|
+
|
|
160
|
+
if sessions and self._screenshot_callbacks:
|
|
161
|
+
# Capture screenshots with limited concurrency
|
|
162
|
+
sem = asyncio.Semaphore(3)
|
|
163
|
+
|
|
164
|
+
async def capture_with_sem(session: SessionInfo) -> None:
|
|
165
|
+
async with sem:
|
|
166
|
+
await self._capture_and_deliver(session)
|
|
167
|
+
|
|
168
|
+
await asyncio.gather(
|
|
169
|
+
*(capture_with_sem(s) for s in sessions if not s.stopped),
|
|
170
|
+
return_exceptions=True,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
await asyncio.sleep(POLL_INTERVAL)
|
|
174
|
+
except asyncio.CancelledError:
|
|
175
|
+
pass
|
|
176
|
+
except Exception:
|
|
177
|
+
logger.warning("Browser screenshot polling crashed", exc_info=True)
|
|
178
|
+
|
|
179
|
+
async def _capture_and_deliver(self, session: SessionInfo) -> None:
|
|
180
|
+
"""Capture a screenshot for a session and deliver to callbacks."""
|
|
181
|
+
cli = self._get_playwright_cli()
|
|
182
|
+
if not cli:
|
|
183
|
+
return
|
|
184
|
+
|
|
185
|
+
# Determine interval based on failure count
|
|
186
|
+
if session.consecutive_failures >= MAX_FAILURES_BEFORE_BACKOFF:
|
|
187
|
+
# In backoff mode - only capture every BACKOFF_INTERVAL/POLL_INTERVAL polls
|
|
188
|
+
# We achieve this by checking a simple modulo on failure count
|
|
189
|
+
if session.consecutive_failures % int(BACKOFF_INTERVAL / POLL_INTERVAL) != 0:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
screenshot_dir = self.project_dir / ".playwright-cli"
|
|
193
|
+
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
screenshot_path = screenshot_dir / f"_view_{session.session_name}.png"
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
proc = await asyncio.create_subprocess_exec(
|
|
198
|
+
cli, "-s", session.session_name, "screenshot",
|
|
199
|
+
f"--filename={screenshot_path}",
|
|
200
|
+
stdout=asyncio.subprocess.PIPE,
|
|
201
|
+
stderr=asyncio.subprocess.PIPE,
|
|
202
|
+
cwd=str(self.project_dir),
|
|
203
|
+
)
|
|
204
|
+
_, stderr = await asyncio.wait_for(proc.communicate(), timeout=SCREENSHOT_TIMEOUT)
|
|
205
|
+
|
|
206
|
+
if proc.returncode != 0:
|
|
207
|
+
session.consecutive_failures += 1
|
|
208
|
+
if session.consecutive_failures >= MAX_FAILURES_BEFORE_STOP:
|
|
209
|
+
session.stopped = True
|
|
210
|
+
logger.debug(
|
|
211
|
+
"Stopped polling session %s after %d failures",
|
|
212
|
+
session.session_name, session.consecutive_failures,
|
|
213
|
+
)
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
# Read and encode the screenshot
|
|
217
|
+
if not screenshot_path.exists():
|
|
218
|
+
session.consecutive_failures += 1
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
image_bytes = screenshot_path.read_bytes()
|
|
222
|
+
image_base64 = base64.b64encode(image_bytes).decode("ascii")
|
|
223
|
+
|
|
224
|
+
# Reset failure counter on success
|
|
225
|
+
session.consecutive_failures = 0
|
|
226
|
+
# Re-enable if previously stopped
|
|
227
|
+
session.stopped = False
|
|
228
|
+
|
|
229
|
+
screenshot = ScreenshotData(
|
|
230
|
+
session_name=session.session_name,
|
|
231
|
+
agent_index=session.agent_index,
|
|
232
|
+
agent_type=session.agent_type,
|
|
233
|
+
feature_id=session.feature_id,
|
|
234
|
+
feature_name=session.feature_name,
|
|
235
|
+
image_base64=image_base64,
|
|
236
|
+
timestamp=datetime.now().isoformat(),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Deliver to all callbacks
|
|
240
|
+
for callback in list(self._screenshot_callbacks):
|
|
241
|
+
try:
|
|
242
|
+
await callback(screenshot)
|
|
243
|
+
except Exception:
|
|
244
|
+
pass # Connection may be closed
|
|
245
|
+
|
|
246
|
+
except asyncio.TimeoutError:
|
|
247
|
+
session.consecutive_failures += 1
|
|
248
|
+
except Exception:
|
|
249
|
+
session.consecutive_failures += 1
|
|
250
|
+
finally:
|
|
251
|
+
# Clean up the screenshot file
|
|
252
|
+
try:
|
|
253
|
+
screenshot_path.unlink(missing_ok=True)
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
def _cleanup_screenshot_file(self, session_name: str) -> None:
|
|
258
|
+
"""Remove a session's screenshot file."""
|
|
259
|
+
try:
|
|
260
|
+
path = self.project_dir / ".playwright-cli" / f"_view_{session_name}.png"
|
|
261
|
+
path.unlink(missing_ok=True)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Global instance management (thread-safe)
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
_services: dict[tuple[str, str], BrowserViewService] = {}
|
|
271
|
+
_services_lock = threading.Lock()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get_browser_view_service(project_name: str, project_dir: Path) -> BrowserViewService:
|
|
275
|
+
"""Get or create a BrowserViewService for a project (thread-safe)."""
|
|
276
|
+
with _services_lock:
|
|
277
|
+
key = (project_name, str(project_dir.resolve()))
|
|
278
|
+
if key not in _services:
|
|
279
|
+
_services[key] = BrowserViewService(project_name, project_dir)
|
|
280
|
+
return _services[key]
|
|
@@ -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
|
|