code-puppy 0.0.341__py3-none-any.whl → 0.0.361__py3-none-any.whl
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.
- code_puppy/agents/__init__.py +2 -0
- code_puppy/agents/agent_manager.py +49 -0
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/base_agent.py +34 -252
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +73 -0
- code_puppy/claude_cache_client.py +249 -34
- code_puppy/cli_runner.py +4 -3
- code_puppy/command_line/add_model_menu.py +8 -9
- code_puppy/command_line/core_commands.py +85 -0
- code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
- code_puppy/command_line/mcp/custom_server_form.py +54 -19
- code_puppy/command_line/mcp/custom_server_installer.py +8 -9
- code_puppy/command_line/mcp/handler.py +0 -2
- code_puppy/command_line/mcp/help_command.py +1 -5
- code_puppy/command_line/mcp/start_command.py +36 -18
- code_puppy/command_line/onboarding_slides.py +0 -1
- code_puppy/command_line/prompt_toolkit_completion.py +16 -10
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +66 -62
- code_puppy/mcp_/async_lifecycle.py +35 -4
- code_puppy/mcp_/managed_server.py +49 -20
- code_puppy/mcp_/manager.py +81 -52
- code_puppy/messaging/__init__.py +15 -0
- code_puppy/messaging/message_queue.py +11 -23
- code_puppy/messaging/messages.py +27 -0
- code_puppy/messaging/queue_console.py +1 -1
- code_puppy/messaging/rich_renderer.py +36 -1
- code_puppy/messaging/spinner/__init__.py +20 -2
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_utils.py +54 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +90 -19
- code_puppy/plugins/antigravity_oauth/transport.py +1 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/status_display.py +6 -2
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +139 -36
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +6 -6
- code_puppy/tools/browser/browser_interactions.py +21 -20
- code_puppy/tools/browser/browser_locators.py +9 -9
- code_puppy/tools/browser/browser_navigation.py +7 -7
- code_puppy/tools/browser/browser_screenshot.py +78 -140
- code_puppy/tools/browser/browser_scripts.py +15 -13
- code_puppy/tools/browser/camoufox_manager.py +226 -64
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +292 -101
- code_puppy/tools/common.py +176 -1
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/subagent_context.py +158 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/METADATA +13 -11
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/RECORD +84 -53
- code_puppy/command_line/mcp/add_command.py +0 -170
- code_puppy/tools/browser/vqa_agent.py +0 -90
- {code_puppy-0.0.341.data → code_puppy-0.0.361.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.341.data → code_puppy-0.0.361.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.341.dist-info → code_puppy-0.0.361.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,5 +1,11 @@
|
|
|
1
|
-
"""Camoufox browser manager - privacy-focused Firefox automation.
|
|
1
|
+
"""Camoufox browser manager - privacy-focused Firefox automation.
|
|
2
2
|
|
|
3
|
+
Supports multiple simultaneous instances with unique profile directories.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import atexit
|
|
8
|
+
import contextvars
|
|
3
9
|
import os
|
|
4
10
|
from pathlib import Path
|
|
5
11
|
from typing import Optional
|
|
@@ -9,70 +15,121 @@ from playwright.async_api import Browser, BrowserContext, Page
|
|
|
9
15
|
from code_puppy import config
|
|
10
16
|
from code_puppy.messaging import emit_info, emit_success, emit_warning
|
|
11
17
|
|
|
18
|
+
# Store active manager instances by session ID
|
|
19
|
+
_active_managers: dict[str, "CamoufoxManager"] = {}
|
|
20
|
+
|
|
21
|
+
# Context variable for browser session - properly inherits through async tasks
|
|
22
|
+
# This allows parallel agent invocations to each have their own browser instance
|
|
23
|
+
_browser_session_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar(
|
|
24
|
+
"browser_session", default=None
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_browser_session(session_id: Optional[str]) -> contextvars.Token:
|
|
29
|
+
"""Set the browser session ID for the current context.
|
|
30
|
+
|
|
31
|
+
This must be called BEFORE any tool calls that use the browser.
|
|
32
|
+
The context will properly propagate to all subsequent async calls.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
session_id: The session ID to use for browser operations.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A token that can be used to reset the context.
|
|
39
|
+
"""
|
|
40
|
+
return _browser_session_var.set(session_id)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_browser_session() -> Optional[str]:
|
|
44
|
+
"""Get the browser session ID for the current context.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The current session ID, or None if not set.
|
|
48
|
+
"""
|
|
49
|
+
return _browser_session_var.get()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_session_browser_manager() -> "CamoufoxManager":
|
|
53
|
+
"""Get the CamoufoxManager for the current context's session.
|
|
54
|
+
|
|
55
|
+
This is the preferred way to get a browser manager in tool functions,
|
|
56
|
+
as it automatically uses the correct session ID for the current
|
|
57
|
+
agent context.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
A CamoufoxManager instance for the current session.
|
|
61
|
+
"""
|
|
62
|
+
session_id = get_browser_session()
|
|
63
|
+
return get_camoufox_manager(session_id)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Flag to track if cleanup has already run
|
|
67
|
+
_cleanup_done: bool = False
|
|
68
|
+
|
|
12
69
|
|
|
13
70
|
class CamoufoxManager:
|
|
14
|
-
"""
|
|
71
|
+
"""Browser manager for Camoufox (privacy-focused Firefox) automation.
|
|
72
|
+
|
|
73
|
+
Supports multiple simultaneous instances, each with its own profile directory.
|
|
74
|
+
"""
|
|
15
75
|
|
|
16
|
-
_instance: Optional["CamoufoxManager"] = None
|
|
17
76
|
_browser: Optional[Browser] = None
|
|
18
77
|
_context: Optional[BrowserContext] = None
|
|
19
78
|
_initialized: bool = False
|
|
20
79
|
|
|
21
|
-
def
|
|
22
|
-
|
|
23
|
-
cls._instance = super().__new__(cls)
|
|
24
|
-
return cls._instance
|
|
80
|
+
def __init__(self, session_id: Optional[str] = None):
|
|
81
|
+
"""Initialize manager settings.
|
|
25
82
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
self.
|
|
83
|
+
Args:
|
|
84
|
+
session_id: Optional session ID for this instance.
|
|
85
|
+
If None, uses 'default' as the session ID.
|
|
86
|
+
"""
|
|
87
|
+
self.session_id = session_id or "default"
|
|
31
88
|
|
|
32
89
|
# Default to headless=True (no browser spam during tests)
|
|
33
90
|
# Override with BROWSER_HEADLESS=false to see the browser
|
|
34
91
|
self.headless = os.getenv("BROWSER_HEADLESS", "true").lower() != "false"
|
|
35
92
|
self.homepage = "https://www.google.com"
|
|
93
|
+
# Browser type: "chromium" skips Camoufox entirely, "firefox"/"camoufox" uses Camoufox
|
|
94
|
+
self.browser_type = "chromium" # Default to Chromium for reliability
|
|
36
95
|
# Camoufox-specific settings
|
|
37
96
|
self.geoip = True # Enable GeoIP spoofing
|
|
38
97
|
self.block_webrtc = True # Block WebRTC for privacy
|
|
39
98
|
self.humanize = True # Add human-like behavior
|
|
40
99
|
|
|
41
|
-
#
|
|
100
|
+
# Unique profile directory per session for browser state
|
|
42
101
|
self.profile_dir = self._get_profile_directory()
|
|
43
102
|
|
|
44
|
-
@classmethod
|
|
45
|
-
def get_instance(cls) -> "CamoufoxManager":
|
|
46
|
-
"""Get the singleton instance."""
|
|
47
|
-
if cls._instance is None:
|
|
48
|
-
cls._instance = cls()
|
|
49
|
-
return cls._instance
|
|
50
|
-
|
|
51
103
|
def _get_profile_directory(self) -> Path:
|
|
52
|
-
"""Get or create the
|
|
104
|
+
"""Get or create the profile directory for this session.
|
|
53
105
|
|
|
54
|
-
|
|
55
|
-
|
|
106
|
+
Each session gets its own profile directory under:
|
|
107
|
+
XDG_CACHE_HOME/code_puppy/camoufox_profiles/<session_id>/
|
|
108
|
+
|
|
109
|
+
This allows multiple instances to run simultaneously.
|
|
56
110
|
"""
|
|
57
111
|
cache_dir = Path(config.CACHE_DIR)
|
|
58
|
-
|
|
112
|
+
profiles_base = cache_dir / "camoufox_profiles"
|
|
113
|
+
profile_path = profiles_base / self.session_id
|
|
59
114
|
profile_path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
60
115
|
return profile_path
|
|
61
116
|
|
|
62
117
|
async def async_initialize(self) -> None:
|
|
63
|
-
"""Initialize Camoufox
|
|
118
|
+
"""Initialize browser (Chromium or Camoufox based on browser_type)."""
|
|
64
119
|
if self._initialized:
|
|
65
120
|
return
|
|
66
121
|
|
|
67
122
|
try:
|
|
68
|
-
|
|
123
|
+
browser_name = "Chromium" if self.browser_type == "chromium" else "Camoufox"
|
|
124
|
+
emit_info(f"Initializing {browser_name} (session: {self.session_id})...")
|
|
69
125
|
|
|
70
|
-
#
|
|
71
|
-
|
|
126
|
+
# Only prefetch Camoufox if we're going to use it
|
|
127
|
+
if self.browser_type != "chromium":
|
|
128
|
+
await self._prefetch_camoufox()
|
|
72
129
|
|
|
73
130
|
await self._initialize_camoufox()
|
|
74
131
|
# emit_info(
|
|
75
|
-
# "[green]✅
|
|
132
|
+
# "[green]✅ Browser initialized successfully[/green]"
|
|
76
133
|
# ) # Removed to reduce console spam
|
|
77
134
|
self._initialized = True
|
|
78
135
|
|
|
@@ -81,8 +138,18 @@ class CamoufoxManager:
|
|
|
81
138
|
raise
|
|
82
139
|
|
|
83
140
|
async def _initialize_camoufox(self) -> None:
|
|
84
|
-
"""Try to start
|
|
141
|
+
"""Try to start browser with the configured settings.
|
|
142
|
+
|
|
143
|
+
If browser_type is 'chromium', skips Camoufox and uses Playwright Chromium directly.
|
|
144
|
+
Otherwise, tries Camoufox first and falls back to Chromium on failure.
|
|
145
|
+
"""
|
|
85
146
|
emit_info(f"Using persistent profile: {self.profile_dir}")
|
|
147
|
+
|
|
148
|
+
# If chromium is explicitly requested, skip Camoufox entirely
|
|
149
|
+
if self.browser_type == "chromium":
|
|
150
|
+
await self._initialize_chromium()
|
|
151
|
+
return
|
|
152
|
+
|
|
86
153
|
# Lazy import camoufox to avoid triggering heavy optional deps at import time
|
|
87
154
|
try:
|
|
88
155
|
import camoufox
|
|
@@ -103,19 +170,24 @@ class CamoufoxManager:
|
|
|
103
170
|
self._context = await camoufox_instance.start()
|
|
104
171
|
self._initialized = True
|
|
105
172
|
except Exception:
|
|
106
|
-
from playwright.async_api import async_playwright
|
|
107
|
-
|
|
108
173
|
emit_warning(
|
|
109
|
-
"Camoufox
|
|
110
|
-
)
|
|
111
|
-
pw = await async_playwright().start()
|
|
112
|
-
# Use persistent context directory for Chromium to emulate previous behavior
|
|
113
|
-
context = await pw.chromium.launch_persistent_context(
|
|
114
|
-
user_data_dir=str(self.profile_dir), headless=self.headless
|
|
174
|
+
"Camoufox not available. Falling back to Playwright (Chromium)."
|
|
115
175
|
)
|
|
116
|
-
self.
|
|
117
|
-
|
|
118
|
-
|
|
176
|
+
await self._initialize_chromium()
|
|
177
|
+
|
|
178
|
+
async def _initialize_chromium(self) -> None:
|
|
179
|
+
"""Initialize Playwright Chromium browser."""
|
|
180
|
+
from playwright.async_api import async_playwright
|
|
181
|
+
|
|
182
|
+
emit_info("Initializing Chromium browser...")
|
|
183
|
+
pw = await async_playwright().start()
|
|
184
|
+
# Use persistent context directory for Chromium to preserve browser state
|
|
185
|
+
context = await pw.chromium.launch_persistent_context(
|
|
186
|
+
user_data_dir=str(self.profile_dir), headless=self.headless
|
|
187
|
+
)
|
|
188
|
+
self._context = context
|
|
189
|
+
self._browser = context.browser
|
|
190
|
+
self._initialized = True
|
|
119
191
|
|
|
120
192
|
async def get_current_page(self) -> Optional[Page]:
|
|
121
193
|
"""Get the currently active page. Lazily creates one if none exist."""
|
|
@@ -187,49 +259,139 @@ class CamoufoxManager:
|
|
|
187
259
|
return []
|
|
188
260
|
return self._context.pages
|
|
189
261
|
|
|
190
|
-
async def _cleanup(self) -> None:
|
|
191
|
-
"""Clean up browser resources and save persistent state.
|
|
262
|
+
async def _cleanup(self, silent: bool = False) -> None:
|
|
263
|
+
"""Clean up browser resources and save persistent state.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
silent: If True, suppress all errors (used during shutdown).
|
|
267
|
+
"""
|
|
192
268
|
try:
|
|
193
269
|
# Save browser state before closing (cookies, localStorage, etc.)
|
|
194
270
|
if self._context:
|
|
195
271
|
try:
|
|
196
272
|
storage_state_path = self.profile_dir / "storage_state.json"
|
|
197
273
|
await self._context.storage_state(path=str(storage_state_path))
|
|
198
|
-
|
|
274
|
+
if not silent:
|
|
275
|
+
emit_success(f"Browser state saved to {storage_state_path}")
|
|
199
276
|
except Exception as e:
|
|
200
|
-
|
|
277
|
+
if not silent:
|
|
278
|
+
emit_warning(f"Could not save storage state: {e}")
|
|
201
279
|
|
|
202
|
-
|
|
280
|
+
try:
|
|
281
|
+
await self._context.close()
|
|
282
|
+
except Exception:
|
|
283
|
+
pass # Ignore errors during context close
|
|
203
284
|
self._context = None
|
|
285
|
+
|
|
204
286
|
if self._browser:
|
|
205
|
-
|
|
287
|
+
try:
|
|
288
|
+
await self._browser.close()
|
|
289
|
+
except Exception:
|
|
290
|
+
pass # Ignore errors during browser close
|
|
206
291
|
self._browser = None
|
|
292
|
+
|
|
207
293
|
self._initialized = False
|
|
294
|
+
|
|
295
|
+
# Remove from active managers
|
|
296
|
+
if self.session_id in _active_managers:
|
|
297
|
+
del _active_managers[self.session_id]
|
|
298
|
+
|
|
208
299
|
except Exception as e:
|
|
209
|
-
|
|
300
|
+
if not silent:
|
|
301
|
+
emit_warning(f"Warning during cleanup: {e}")
|
|
210
302
|
|
|
211
303
|
async def close(self) -> None:
|
|
212
304
|
"""Close the browser and clean up resources."""
|
|
213
305
|
await self._cleanup()
|
|
214
|
-
emit_info("Camoufox browser closed")
|
|
306
|
+
emit_info(f"Camoufox browser closed (session: {self.session_id})")
|
|
215
307
|
|
|
216
|
-
def __del__(self):
|
|
217
|
-
"""Ensure cleanup on object destruction."""
|
|
218
|
-
# Note: Can't use async in __del__, so this is just a fallback
|
|
219
|
-
if self._initialized:
|
|
220
|
-
import asyncio
|
|
221
308
|
|
|
309
|
+
def get_camoufox_manager(session_id: Optional[str] = None) -> CamoufoxManager:
|
|
310
|
+
"""Get or create a CamoufoxManager instance.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
session_id: Optional session ID. If provided and a manager with this
|
|
314
|
+
session exists, returns that manager. Otherwise creates a new one.
|
|
315
|
+
If None, uses 'default' as the session ID.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
A CamoufoxManager instance.
|
|
319
|
+
|
|
320
|
+
Example:
|
|
321
|
+
# Default session (for single-agent use)
|
|
322
|
+
manager = get_camoufox_manager()
|
|
323
|
+
|
|
324
|
+
# Named session (for multi-agent use)
|
|
325
|
+
manager = get_camoufox_manager("qa-agent-1")
|
|
326
|
+
"""
|
|
327
|
+
session_id = session_id or "default"
|
|
328
|
+
|
|
329
|
+
if session_id not in _active_managers:
|
|
330
|
+
_active_managers[session_id] = CamoufoxManager(session_id)
|
|
331
|
+
|
|
332
|
+
return _active_managers[session_id]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def cleanup_all_browsers() -> None:
|
|
336
|
+
"""Close all active browser manager instances.
|
|
337
|
+
|
|
338
|
+
This should be called before application exit to ensure all browser
|
|
339
|
+
connections are properly closed and no dangling futures remain.
|
|
340
|
+
"""
|
|
341
|
+
global _cleanup_done
|
|
342
|
+
|
|
343
|
+
if _cleanup_done:
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
_cleanup_done = True
|
|
347
|
+
|
|
348
|
+
# Get a copy of the keys since we'll be modifying the dict during cleanup
|
|
349
|
+
session_ids = list(_active_managers.keys())
|
|
350
|
+
|
|
351
|
+
for session_id in session_ids:
|
|
352
|
+
manager = _active_managers.get(session_id)
|
|
353
|
+
if manager and manager._initialized:
|
|
222
354
|
try:
|
|
223
|
-
|
|
224
|
-
if loop.is_running():
|
|
225
|
-
loop.create_task(self._cleanup())
|
|
226
|
-
else:
|
|
227
|
-
loop.run_until_complete(self._cleanup())
|
|
355
|
+
await manager._cleanup(silent=True)
|
|
228
356
|
except Exception:
|
|
229
|
-
pass #
|
|
357
|
+
pass # Silently ignore all errors during exit cleanup
|
|
230
358
|
|
|
231
359
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
360
|
+
def _sync_cleanup_browsers() -> None:
|
|
361
|
+
"""Synchronous cleanup wrapper for use with atexit.
|
|
362
|
+
|
|
363
|
+
Creates a new event loop to run the async cleanup since the main
|
|
364
|
+
event loop may have already been closed when atexit handlers run.
|
|
365
|
+
"""
|
|
366
|
+
global _cleanup_done
|
|
367
|
+
|
|
368
|
+
if _cleanup_done or not _active_managers:
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
try:
|
|
372
|
+
# Try to get the running loop first
|
|
373
|
+
try:
|
|
374
|
+
loop = asyncio.get_running_loop()
|
|
375
|
+
# If we're in an async context, schedule the cleanup
|
|
376
|
+
# but this is unlikely in atexit handlers
|
|
377
|
+
loop.create_task(cleanup_all_browsers())
|
|
378
|
+
return
|
|
379
|
+
except RuntimeError:
|
|
380
|
+
pass # No running loop, which is expected in atexit
|
|
381
|
+
|
|
382
|
+
# Create a new event loop for cleanup
|
|
383
|
+
loop = asyncio.new_event_loop()
|
|
384
|
+
asyncio.set_event_loop(loop)
|
|
385
|
+
try:
|
|
386
|
+
loop.run_until_complete(cleanup_all_browsers())
|
|
387
|
+
finally:
|
|
388
|
+
loop.close()
|
|
389
|
+
except Exception:
|
|
390
|
+
# Silently swallow ALL errors during exit cleanup
|
|
391
|
+
# We don't want to spam the user with errors on exit
|
|
392
|
+
pass
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# Register the cleanup handler with atexit
|
|
396
|
+
# This ensures browsers are closed even if close_browser() isn't explicitly called
|
|
397
|
+
atexit.register(_sync_cleanup_browsers)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Chromium Terminal Manager - Simple Chromium browser for terminal use.
|
|
2
|
+
|
|
3
|
+
This module provides a browser manager for Chromium terminal automation.
|
|
4
|
+
Each instance gets its own ephemeral browser context, allowing multiple
|
|
5
|
+
terminal QA agents to run simultaneously without profile conflicts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
|
13
|
+
|
|
14
|
+
from code_puppy.messaging import emit_info, emit_success
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
# Store active manager instances by session ID
|
|
19
|
+
_active_managers: dict[str, "ChromiumTerminalManager"] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ChromiumTerminalManager:
|
|
23
|
+
"""Browser manager for Chromium terminal automation.
|
|
24
|
+
|
|
25
|
+
Each instance gets its own ephemeral browser context, allowing multiple
|
|
26
|
+
terminal QA agents to run simultaneously without profile conflicts.
|
|
27
|
+
|
|
28
|
+
Key features:
|
|
29
|
+
- Ephemeral contexts (no profile locking issues)
|
|
30
|
+
- Multiple instances can run simultaneously
|
|
31
|
+
- Visible (headless=False) by default for terminal use
|
|
32
|
+
- Simple API: initialize, get_current_page, new_page, close
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
manager = get_chromium_terminal_manager() # or with session_id
|
|
36
|
+
await manager.async_initialize()
|
|
37
|
+
page = await manager.get_current_page()
|
|
38
|
+
await page.goto("https://example.com")
|
|
39
|
+
await manager.close()
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_browser: Optional[Browser] = None
|
|
43
|
+
_context: Optional[BrowserContext] = None
|
|
44
|
+
_playwright: Optional[object] = None
|
|
45
|
+
_initialized: bool = False
|
|
46
|
+
|
|
47
|
+
def __init__(self, session_id: Optional[str] = None) -> None:
|
|
48
|
+
"""Initialize manager settings.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
session_id: Optional session ID for tracking this instance.
|
|
52
|
+
If None, a UUID will be generated.
|
|
53
|
+
"""
|
|
54
|
+
import os
|
|
55
|
+
|
|
56
|
+
self.session_id = session_id or str(uuid.uuid4())[:8]
|
|
57
|
+
|
|
58
|
+
# Default to headless=False - we want to see the terminal browser!
|
|
59
|
+
# Can override with CHROMIUM_HEADLESS=true if needed
|
|
60
|
+
self.headless = os.getenv("CHROMIUM_HEADLESS", "false").lower() == "true"
|
|
61
|
+
|
|
62
|
+
logger.debug(
|
|
63
|
+
f"ChromiumTerminalManager created: session={self.session_id}, "
|
|
64
|
+
f"headless={self.headless}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
async def async_initialize(self) -> None:
|
|
68
|
+
"""Initialize the Chromium browser.
|
|
69
|
+
|
|
70
|
+
Launches a Chromium browser with an ephemeral context. The browser
|
|
71
|
+
runs in visible mode by default (headless=False) for terminal use.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
Exception: If browser initialization fails.
|
|
75
|
+
"""
|
|
76
|
+
if self._initialized:
|
|
77
|
+
logger.debug(
|
|
78
|
+
f"ChromiumTerminalManager {self.session_id} already initialized"
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
emit_info(
|
|
84
|
+
f"Initializing Chromium terminal browser (session: {self.session_id})..."
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Start Playwright
|
|
88
|
+
self._playwright = await async_playwright().start()
|
|
89
|
+
|
|
90
|
+
# Launch browser (not persistent - allows multiple instances)
|
|
91
|
+
self._browser = await self._playwright.chromium.launch(
|
|
92
|
+
headless=self.headless,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Create ephemeral context
|
|
96
|
+
self._context = await self._browser.new_context()
|
|
97
|
+
self._initialized = True
|
|
98
|
+
|
|
99
|
+
emit_success(
|
|
100
|
+
f"Chromium terminal browser initialized (session: {self.session_id})"
|
|
101
|
+
)
|
|
102
|
+
logger.info(
|
|
103
|
+
f"Chromium initialized: session={self.session_id}, headless={self.headless}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.error(f"Failed to initialize Chromium: {e}")
|
|
108
|
+
await self._cleanup()
|
|
109
|
+
raise
|
|
110
|
+
|
|
111
|
+
async def get_current_page(self) -> Optional[Page]:
|
|
112
|
+
"""Get the currently active page, creating one if none exist.
|
|
113
|
+
|
|
114
|
+
Lazily initializes the browser if not already initialized.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
The current page, or None if context is unavailable.
|
|
118
|
+
"""
|
|
119
|
+
if not self._initialized or not self._context:
|
|
120
|
+
await self.async_initialize()
|
|
121
|
+
|
|
122
|
+
if not self._context:
|
|
123
|
+
logger.warning("No browser context available")
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
pages = self._context.pages
|
|
127
|
+
if pages:
|
|
128
|
+
return pages[0]
|
|
129
|
+
|
|
130
|
+
# Create a new blank page if none exist
|
|
131
|
+
logger.debug("No existing pages, creating new blank page")
|
|
132
|
+
return await self._context.new_page()
|
|
133
|
+
|
|
134
|
+
async def new_page(self, url: Optional[str] = None) -> Page:
|
|
135
|
+
"""Create a new page, optionally navigating to a URL.
|
|
136
|
+
|
|
137
|
+
Lazily initializes the browser if not already initialized.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
url: Optional URL to navigate to after creating the page.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The newly created page.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
RuntimeError: If browser context is not available.
|
|
147
|
+
"""
|
|
148
|
+
if not self._initialized:
|
|
149
|
+
await self.async_initialize()
|
|
150
|
+
|
|
151
|
+
if not self._context:
|
|
152
|
+
raise RuntimeError("Browser context not available")
|
|
153
|
+
|
|
154
|
+
page = await self._context.new_page()
|
|
155
|
+
logger.debug(f"Created new page{f' navigating to {url}' if url else ''}")
|
|
156
|
+
|
|
157
|
+
if url:
|
|
158
|
+
await page.goto(url)
|
|
159
|
+
|
|
160
|
+
return page
|
|
161
|
+
|
|
162
|
+
async def close_page(self, page: Page) -> None:
|
|
163
|
+
"""Close a specific page.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
page: The page to close.
|
|
167
|
+
"""
|
|
168
|
+
await page.close()
|
|
169
|
+
logger.debug("Page closed")
|
|
170
|
+
|
|
171
|
+
async def get_all_pages(self) -> list[Page]:
|
|
172
|
+
"""Get all open pages.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
List of all open pages, or empty list if no context.
|
|
176
|
+
"""
|
|
177
|
+
if not self._context:
|
|
178
|
+
return []
|
|
179
|
+
return self._context.pages
|
|
180
|
+
|
|
181
|
+
async def _cleanup(self, silent: bool = False) -> None:
|
|
182
|
+
"""Clean up browser resources.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
silent: If True, suppress all errors (used during shutdown).
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
if self._context:
|
|
189
|
+
try:
|
|
190
|
+
await self._context.close()
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
self._context = None
|
|
194
|
+
|
|
195
|
+
if self._browser:
|
|
196
|
+
try:
|
|
197
|
+
await self._browser.close()
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
self._browser = None
|
|
201
|
+
|
|
202
|
+
if self._playwright:
|
|
203
|
+
try:
|
|
204
|
+
await self._playwright.stop()
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
self._playwright = None
|
|
208
|
+
|
|
209
|
+
self._initialized = False
|
|
210
|
+
|
|
211
|
+
# Remove from active managers
|
|
212
|
+
if self.session_id in _active_managers:
|
|
213
|
+
del _active_managers[self.session_id]
|
|
214
|
+
|
|
215
|
+
if not silent:
|
|
216
|
+
logger.debug(
|
|
217
|
+
f"Browser resources cleaned up (session: {self.session_id})"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
except Exception as e:
|
|
221
|
+
if not silent:
|
|
222
|
+
logger.warning(f"Warning during cleanup: {e}")
|
|
223
|
+
|
|
224
|
+
async def close(self) -> None:
|
|
225
|
+
"""Close the browser and clean up all resources.
|
|
226
|
+
|
|
227
|
+
This properly shuts down the browser and releases all resources.
|
|
228
|
+
Should be called when done with the browser.
|
|
229
|
+
"""
|
|
230
|
+
await self._cleanup()
|
|
231
|
+
emit_info(f"Chromium terminal browser closed (session: {self.session_id})")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def get_chromium_terminal_manager(
|
|
235
|
+
session_id: Optional[str] = None,
|
|
236
|
+
) -> ChromiumTerminalManager:
|
|
237
|
+
"""Get or create a ChromiumTerminalManager instance.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
session_id: Optional session ID. If provided and a manager with this
|
|
241
|
+
session exists, returns that manager. Otherwise creates a new one.
|
|
242
|
+
If None, uses 'default' as the session ID.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
A ChromiumTerminalManager instance.
|
|
246
|
+
|
|
247
|
+
Example:
|
|
248
|
+
# Default session (for single-agent use)
|
|
249
|
+
manager = get_chromium_terminal_manager()
|
|
250
|
+
|
|
251
|
+
# Named session (for multi-agent use)
|
|
252
|
+
manager = get_chromium_terminal_manager("agent-1")
|
|
253
|
+
"""
|
|
254
|
+
session_id = session_id or "default"
|
|
255
|
+
|
|
256
|
+
if session_id not in _active_managers:
|
|
257
|
+
_active_managers[session_id] = ChromiumTerminalManager(session_id)
|
|
258
|
+
|
|
259
|
+
return _active_managers[session_id]
|