autoforge-ai 0.1.18 → 0.1.20
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/README.md +10 -4
- package/auth.py +16 -6
- package/autonomous_agent_demo.py +2 -2
- package/package.json +1 -1
- package/server/routers/agent.py +6 -7
- package/server/routers/settings.py +4 -4
- package/server/services/browser_view_service.py +280 -0
- package/server/websocket.py +98 -1
- package/start.py +2 -2
- package/ui/dist/assets/index-B5-x5jW8.js +96 -0
- package/ui/dist/assets/index-DkJ1zszK.css +1 -0
- package/ui/dist/assets/vendor-utils-CnTXttNm.js +2 -0
- package/ui/dist/index.html +3 -3
- package/ui/dist/assets/index-DXm5cuJA.js +0 -96
- package/ui/dist/assets/index-DlYws_VI.css +0 -1
- package/ui/dist/assets/vendor-utils-CJmVD20L.js +0 -2
package/README.md
CHANGED
|
@@ -4,6 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
A long-running autonomous coding agent powered by the Claude Agent SDK. This tool can build complete applications over multiple sessions using a two-agent pattern (initializer + coding agent). Includes a React-based UI for monitoring progress in real-time.
|
|
6
6
|
|
|
7
|
+
> [!WARNING]
|
|
8
|
+
> **Authentication:** Anthropic's policy states that third-party developers may not offer `claude.ai` login or subscription-based rate limits for their products (including agents built on the Claude Agent SDK) unless previously approved. Using your Claude subscription with AutoForge may risk account suspension. We recommend using an API key from [console.anthropic.com](https://console.anthropic.com/) instead.
|
|
9
|
+
|
|
10
|
+
> [!NOTE]
|
|
11
|
+
> **This repository is no longer actively maintained.** Most agent coding tools now ship their own long-running harnesses, making this project less necessary. Feel free to fork and continue development on your own!
|
|
12
|
+
|
|
7
13
|
## Video Tutorial
|
|
8
14
|
|
|
9
15
|
[](https://youtu.be/nKiPOxDpcJY)
|
|
@@ -34,8 +40,8 @@ irm https://claude.ai/install.ps1 | iex
|
|
|
34
40
|
|
|
35
41
|
You need one of the following:
|
|
36
42
|
|
|
37
|
-
- **
|
|
38
|
-
- **
|
|
43
|
+
- **Anthropic API Key** (recommended) - Pay-per-use from https://console.anthropic.com/
|
|
44
|
+
- **Claude Pro/Max Subscription** - Use `claude login` to authenticate (see warning above)
|
|
39
45
|
|
|
40
46
|
---
|
|
41
47
|
|
|
@@ -101,7 +107,7 @@ This launches the React-based web UI at `http://localhost:5173` with:
|
|
|
101
107
|
|
|
102
108
|
The start script will:
|
|
103
109
|
1. Check if Claude CLI is installed
|
|
104
|
-
2. Check if you're authenticated (prompt to
|
|
110
|
+
2. Check if you're authenticated (prompt to configure authentication if not)
|
|
105
111
|
3. Create a Python virtual environment
|
|
106
112
|
4. Install dependencies
|
|
107
113
|
5. Launch the main menu
|
|
@@ -371,7 +377,7 @@ Edit `security.py` to add or remove commands from `ALLOWED_COMMANDS`.
|
|
|
371
377
|
Install the Claude Code CLI using the instructions in the Prerequisites section.
|
|
372
378
|
|
|
373
379
|
**"Not authenticated with Claude"**
|
|
374
|
-
|
|
380
|
+
Set your API key via `ANTHROPIC_API_KEY` environment variable or the Settings UI. Alternatively, run `claude login` to use subscription credentials, but note that Anthropic's policy may not permit subscription-based auth for third-party agents.
|
|
375
381
|
|
|
376
382
|
**"Appears to hang on first run"**
|
|
377
383
|
This is normal. The initializer agent is generating detailed test cases, which takes significant time. Watch for `[Tool: ...]` output to confirm the agent is working.
|
package/auth.py
CHANGED
|
@@ -53,11 +53,16 @@ AUTH_ERROR_HELP_CLI = """
|
|
|
53
53
|
|
|
54
54
|
Claude CLI requires authentication to work.
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
Option 1 (Recommended): Set an API key
|
|
57
|
+
export ANTHROPIC_API_KEY=your-key-here
|
|
58
|
+
Get a key at: https://console.anthropic.com/
|
|
59
|
+
|
|
60
|
+
Option 2: Use subscription login
|
|
57
61
|
claude login
|
|
58
62
|
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
Note: Anthropic's policy may not permit using
|
|
64
|
+
subscription auth with third-party agents.
|
|
65
|
+
API key authentication is recommended.
|
|
61
66
|
==================================================
|
|
62
67
|
"""
|
|
63
68
|
|
|
@@ -69,11 +74,16 @@ AUTH_ERROR_HELP_SERVER = """
|
|
|
69
74
|
|
|
70
75
|
Claude CLI requires authentication to work.
|
|
71
76
|
|
|
72
|
-
|
|
77
|
+
Option 1 (Recommended): Set an API key
|
|
78
|
+
export ANTHROPIC_API_KEY=your-key-here
|
|
79
|
+
Get a key at: https://console.anthropic.com/
|
|
80
|
+
|
|
81
|
+
Option 2: Use subscription login
|
|
73
82
|
claude login
|
|
74
83
|
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
Note: Anthropic's policy may not permit using
|
|
85
|
+
subscription auth with third-party agents.
|
|
86
|
+
API key authentication is recommended.
|
|
77
87
|
================================================================================
|
|
78
88
|
"""
|
|
79
89
|
|
package/autonomous_agent_demo.py
CHANGED
|
@@ -76,8 +76,8 @@ Examples:
|
|
|
76
76
|
python autonomous_agent_demo.py --project-dir my-app --testing-ratio 0
|
|
77
77
|
|
|
78
78
|
Authentication:
|
|
79
|
-
Uses Claude CLI authentication
|
|
80
|
-
|
|
79
|
+
Uses Claude CLI authentication. API key (ANTHROPIC_API_KEY) is recommended.
|
|
80
|
+
Alternatively run 'claude login', but note Anthropic's policy may restrict subscription auth.
|
|
81
81
|
""",
|
|
82
82
|
)
|
|
83
83
|
|
package/package.json
CHANGED
package/server/routers/agent.py
CHANGED
|
@@ -17,11 +17,11 @@ from ..utils.project_helpers import get_project_path as _get_project_path
|
|
|
17
17
|
from ..utils.validation import validate_project_name
|
|
18
18
|
|
|
19
19
|
|
|
20
|
-
def _get_settings_defaults() -> tuple[bool, str, int,
|
|
20
|
+
def _get_settings_defaults() -> tuple[bool, str, int, int, int]:
|
|
21
21
|
"""Get defaults from global settings.
|
|
22
22
|
|
|
23
23
|
Returns:
|
|
24
|
-
Tuple of (yolo_mode, model, testing_agent_ratio,
|
|
24
|
+
Tuple of (yolo_mode, model, testing_agent_ratio, batch_size, testing_batch_size)
|
|
25
25
|
"""
|
|
26
26
|
import sys
|
|
27
27
|
root = Path(__file__).parent.parent.parent
|
|
@@ -40,8 +40,6 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool, int, int]:
|
|
|
40
40
|
except (ValueError, TypeError):
|
|
41
41
|
testing_agent_ratio = 1
|
|
42
42
|
|
|
43
|
-
playwright_headless = (settings.get("playwright_headless") or "true").lower() == "true"
|
|
44
|
-
|
|
45
43
|
try:
|
|
46
44
|
batch_size = int(settings.get("batch_size", "3"))
|
|
47
45
|
except (ValueError, TypeError):
|
|
@@ -52,7 +50,7 @@ def _get_settings_defaults() -> tuple[bool, str, int, bool, int, int]:
|
|
|
52
50
|
except (ValueError, TypeError):
|
|
53
51
|
testing_batch_size = 3
|
|
54
52
|
|
|
55
|
-
return yolo_mode, model, testing_agent_ratio,
|
|
53
|
+
return yolo_mode, model, testing_agent_ratio, batch_size, testing_batch_size
|
|
56
54
|
|
|
57
55
|
|
|
58
56
|
router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
|
|
@@ -101,7 +99,7 @@ async def start_agent(
|
|
|
101
99
|
manager = get_project_manager(project_name)
|
|
102
100
|
|
|
103
101
|
# Get defaults from global settings if not provided in request
|
|
104
|
-
default_yolo, default_model, default_testing_ratio,
|
|
102
|
+
default_yolo, default_model, default_testing_ratio, default_batch_size, default_testing_batch_size = _get_settings_defaults()
|
|
105
103
|
|
|
106
104
|
yolo_mode = request.yolo_mode if request.yolo_mode is not None else default_yolo
|
|
107
105
|
model = request.model if request.model else default_model
|
|
@@ -111,12 +109,13 @@ async def start_agent(
|
|
|
111
109
|
batch_size = default_batch_size
|
|
112
110
|
testing_batch_size = default_testing_batch_size
|
|
113
111
|
|
|
112
|
+
# Always run headless - the embedded browser view panel replaces desktop windows
|
|
114
113
|
success, message = await manager.start(
|
|
115
114
|
yolo_mode=yolo_mode,
|
|
116
115
|
model=model,
|
|
117
116
|
max_concurrency=max_concurrency,
|
|
118
117
|
testing_agent_ratio=testing_agent_ratio,
|
|
119
|
-
playwright_headless=
|
|
118
|
+
playwright_headless=True,
|
|
120
119
|
batch_size=batch_size,
|
|
121
120
|
testing_batch_size=testing_batch_size,
|
|
122
121
|
)
|
|
@@ -111,7 +111,7 @@ async def get_settings():
|
|
|
111
111
|
glm_mode=glm_mode,
|
|
112
112
|
ollama_mode=ollama_mode,
|
|
113
113
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
|
114
|
-
playwright_headless=
|
|
114
|
+
playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
|
|
115
115
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
|
116
116
|
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
|
|
117
117
|
api_provider=api_provider,
|
|
@@ -133,8 +133,8 @@ async def update_settings(update: SettingsUpdate):
|
|
|
133
133
|
if update.testing_agent_ratio is not None:
|
|
134
134
|
set_setting("testing_agent_ratio", str(update.testing_agent_ratio))
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
136
|
+
# playwright_headless is no longer user-configurable; always headless
|
|
137
|
+
# with embedded browser view panel in the UI
|
|
138
138
|
|
|
139
139
|
if update.batch_size is not None:
|
|
140
140
|
set_setting("batch_size", str(update.batch_size))
|
|
@@ -179,7 +179,7 @@ async def update_settings(update: SettingsUpdate):
|
|
|
179
179
|
glm_mode=glm_mode,
|
|
180
180
|
ollama_mode=ollama_mode,
|
|
181
181
|
testing_agent_ratio=_parse_int(all_settings.get("testing_agent_ratio"), 1),
|
|
182
|
-
playwright_headless=
|
|
182
|
+
playwright_headless=True, # Always headless - embedded browser view replaces desktop windows
|
|
183
183
|
batch_size=_parse_int(all_settings.get("batch_size"), 3),
|
|
184
184
|
testing_batch_size=_parse_int(all_settings.get("testing_batch_size"), 3),
|
|
185
185
|
api_provider=api_provider,
|
|
@@ -0,0 +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]
|
package/server/websocket.py
CHANGED
|
@@ -16,6 +16,7 @@ from typing import Set
|
|
|
16
16
|
from fastapi import WebSocket, WebSocketDisconnect
|
|
17
17
|
|
|
18
18
|
from .schemas import AGENT_MASCOTS
|
|
19
|
+
from .services.browser_view_service import get_browser_view_service
|
|
19
20
|
from .services.chat_constants import ROOT_DIR
|
|
20
21
|
from .services.dev_server_manager import get_devserver_manager
|
|
21
22
|
from .services.process_manager import get_manager
|
|
@@ -787,8 +788,39 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|
|
787
788
|
# Create orchestrator tracker for observability
|
|
788
789
|
orchestrator_tracker = OrchestratorTracker()
|
|
789
790
|
|
|
791
|
+
# Get browser view service for embedded browser screenshots
|
|
792
|
+
browser_view_service = get_browser_view_service(project_name, project_dir)
|
|
793
|
+
browser_view_subscribed = False
|
|
794
|
+
# Counter to mirror orchestrator's testing session naming (testing-0, testing-1, ...)
|
|
795
|
+
testing_session_counter = 0
|
|
796
|
+
# Deferred session registration: store metadata at agent start, register on first browser command.
|
|
797
|
+
# This avoids premature polling failures when agents spend time reading/planning before opening a browser.
|
|
798
|
+
# Key: session_name -> registration kwargs
|
|
799
|
+
pending_browser_sessions: dict[str, dict] = {}
|
|
800
|
+
# Track which feature IDs map to which session names (for deferred lookup)
|
|
801
|
+
feature_to_session: dict[int, str] = {}
|
|
802
|
+
|
|
803
|
+
async def on_screenshot(screenshot):
|
|
804
|
+
"""Handle browser screenshot - send to this WebSocket."""
|
|
805
|
+
try:
|
|
806
|
+
await websocket.send_json({
|
|
807
|
+
"type": "browser_screenshot",
|
|
808
|
+
"sessionName": screenshot.session_name,
|
|
809
|
+
"agentIndex": screenshot.agent_index,
|
|
810
|
+
"agentType": screenshot.agent_type,
|
|
811
|
+
"featureId": screenshot.feature_id,
|
|
812
|
+
"featureName": screenshot.feature_name,
|
|
813
|
+
"imageData": screenshot.image_base64,
|
|
814
|
+
"timestamp": screenshot.timestamp,
|
|
815
|
+
})
|
|
816
|
+
except Exception:
|
|
817
|
+
pass # Connection may be closed
|
|
818
|
+
|
|
819
|
+
browser_view_service.add_screenshot_callback(on_screenshot)
|
|
820
|
+
|
|
790
821
|
async def on_output(line: str):
|
|
791
822
|
"""Handle agent output - broadcast to this WebSocket."""
|
|
823
|
+
nonlocal testing_session_counter
|
|
792
824
|
try:
|
|
793
825
|
# Extract feature ID from line if present
|
|
794
826
|
feature_id = None
|
|
@@ -817,6 +849,48 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|
|
817
849
|
if agent_update:
|
|
818
850
|
await websocket.send_json(agent_update)
|
|
819
851
|
|
|
852
|
+
# Register/unregister browser sessions based on agent lifecycle
|
|
853
|
+
update_state = agent_update.get("state")
|
|
854
|
+
update_type = agent_update.get("agentType", "coding")
|
|
855
|
+
update_feature_id = agent_update.get("featureId", 0)
|
|
856
|
+
update_feature_name = agent_update.get("featureName", "")
|
|
857
|
+
update_agent_index = agent_update.get("agentIndex", 0)
|
|
858
|
+
|
|
859
|
+
if update_state == "thinking" and agent_update.get("thought") in ("Starting work...", "Starting batch work..."):
|
|
860
|
+
# Agent just started - defer browser session registration until
|
|
861
|
+
# we detect an actual playwright-cli open/goto command. This avoids
|
|
862
|
+
# polling failures while the agent is still reading code / planning.
|
|
863
|
+
if update_type == "coding":
|
|
864
|
+
session_name = f"coding-{update_feature_id}"
|
|
865
|
+
else:
|
|
866
|
+
session_name = f"testing-{testing_session_counter}"
|
|
867
|
+
testing_session_counter += 1
|
|
868
|
+
pending_browser_sessions[session_name] = dict(
|
|
869
|
+
session_name=session_name,
|
|
870
|
+
agent_index=update_agent_index,
|
|
871
|
+
agent_type=update_type,
|
|
872
|
+
feature_id=update_feature_id,
|
|
873
|
+
feature_name=update_feature_name,
|
|
874
|
+
)
|
|
875
|
+
feature_to_session[update_feature_id] = session_name
|
|
876
|
+
elif update_state in ("success", "error"):
|
|
877
|
+
# Agent completed - unregister browser session
|
|
878
|
+
if update_type == "coding":
|
|
879
|
+
session_name = f"coding-{update_feature_id}"
|
|
880
|
+
await browser_view_service.unregister_session(session_name)
|
|
881
|
+
pending_browser_sessions.pop(session_name, None)
|
|
882
|
+
feature_to_session.pop(update_feature_id, None)
|
|
883
|
+
# Testing sessions are cleaned up on orchestrator stop
|
|
884
|
+
|
|
885
|
+
# Detect playwright-cli browser commands and activate deferred sessions
|
|
886
|
+
if feature_id is not None and "playwright-cli" in line and any(
|
|
887
|
+
kw in line for kw in ("open ", "goto ", "open\t", "goto\t")
|
|
888
|
+
):
|
|
889
|
+
sess_name = feature_to_session.get(feature_id)
|
|
890
|
+
if sess_name and sess_name in pending_browser_sessions:
|
|
891
|
+
reg = pending_browser_sessions.pop(sess_name)
|
|
892
|
+
await browser_view_service.register_session(**reg)
|
|
893
|
+
|
|
820
894
|
# Also check for orchestrator events and emit orchestrator_update messages
|
|
821
895
|
orch_update = await orchestrator_tracker.process_line(line)
|
|
822
896
|
if orch_update:
|
|
@@ -826,6 +900,7 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|
|
826
900
|
|
|
827
901
|
async def on_status_change(status: str):
|
|
828
902
|
"""Handle status change - broadcast to this WebSocket."""
|
|
903
|
+
nonlocal testing_session_counter
|
|
829
904
|
try:
|
|
830
905
|
await websocket.send_json({
|
|
831
906
|
"type": "agent_status",
|
|
@@ -835,6 +910,10 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|
|
835
910
|
if status in ("stopped", "crashed"):
|
|
836
911
|
await agent_tracker.reset()
|
|
837
912
|
await orchestrator_tracker.reset()
|
|
913
|
+
await browser_view_service.stop()
|
|
914
|
+
testing_session_counter = 0
|
|
915
|
+
pending_browser_sessions.clear()
|
|
916
|
+
feature_to_session.clear()
|
|
838
917
|
except Exception:
|
|
839
918
|
pass # Connection may be closed
|
|
840
919
|
|
|
@@ -908,10 +987,23 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|
|
908
987
|
data = await websocket.receive_text()
|
|
909
988
|
message = json.loads(data)
|
|
910
989
|
|
|
990
|
+
msg_type = message.get("type")
|
|
991
|
+
|
|
911
992
|
# Handle ping
|
|
912
|
-
if
|
|
993
|
+
if msg_type == "ping":
|
|
913
994
|
await websocket.send_json({"type": "pong"})
|
|
914
995
|
|
|
996
|
+
# Handle browser view subscribe/unsubscribe
|
|
997
|
+
elif msg_type == "browser_view_subscribe":
|
|
998
|
+
if not browser_view_subscribed:
|
|
999
|
+
browser_view_subscribed = True
|
|
1000
|
+
await browser_view_service.add_subscriber()
|
|
1001
|
+
|
|
1002
|
+
elif msg_type == "browser_view_unsubscribe":
|
|
1003
|
+
if browser_view_subscribed:
|
|
1004
|
+
browser_view_subscribed = False
|
|
1005
|
+
await browser_view_service.remove_subscriber()
|
|
1006
|
+
|
|
915
1007
|
except WebSocketDisconnect:
|
|
916
1008
|
break
|
|
917
1009
|
except json.JSONDecodeError:
|
|
@@ -935,5 +1027,10 @@ async def project_websocket(websocket: WebSocket, project_name: str):
|
|
|
935
1027
|
devserver_manager.remove_output_callback(on_dev_output)
|
|
936
1028
|
devserver_manager.remove_status_callback(on_dev_status_change)
|
|
937
1029
|
|
|
1030
|
+
# Unregister browser view callbacks and subscriber
|
|
1031
|
+
browser_view_service.remove_screenshot_callback(on_screenshot)
|
|
1032
|
+
if browser_view_subscribed:
|
|
1033
|
+
await browser_view_service.remove_subscriber()
|
|
1034
|
+
|
|
938
1035
|
# Disconnect from manager
|
|
939
1036
|
await manager.disconnect(websocket, project_name)
|
package/start.py
CHANGED
|
@@ -255,7 +255,7 @@ def run_spec_creation(project_dir: Path) -> bool:
|
|
|
255
255
|
print(f"Please ensure app_spec.txt exists in: {get_project_prompts_dir(project_dir)}")
|
|
256
256
|
# If failed with non-zero exit and no spec, might be auth issue
|
|
257
257
|
if result.returncode != 0:
|
|
258
|
-
print("\nIf you're having authentication issues, try
|
|
258
|
+
print("\nIf you're having authentication issues, set ANTHROPIC_API_KEY or try: claude login")
|
|
259
259
|
return False
|
|
260
260
|
|
|
261
261
|
except FileNotFoundError:
|
|
@@ -416,7 +416,7 @@ def run_agent(project_name: str, project_dir: Path) -> None:
|
|
|
416
416
|
print(f"\nAgent error:\n{stderr_output.strip()}")
|
|
417
417
|
# Still hint about auth if exit was unexpected
|
|
418
418
|
if "error" in stderr_output.lower() or "exception" in stderr_output.lower():
|
|
419
|
-
print("\nIf this is an authentication issue, try
|
|
419
|
+
print("\nIf this is an authentication issue, set ANTHROPIC_API_KEY or try: claude login")
|
|
420
420
|
|
|
421
421
|
except KeyboardInterrupt:
|
|
422
422
|
print("\n\nAgent interrupted. Run again to resume.")
|