code-puppy 0.0.348__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.
Files changed (70) hide show
  1. code_puppy/agents/__init__.py +2 -0
  2. code_puppy/agents/agent_manager.py +49 -0
  3. code_puppy/agents/agent_pack_leader.py +383 -0
  4. code_puppy/agents/agent_qa_kitten.py +12 -7
  5. code_puppy/agents/agent_terminal_qa.py +323 -0
  6. code_puppy/agents/base_agent.py +17 -4
  7. code_puppy/agents/event_stream_handler.py +101 -8
  8. code_puppy/agents/pack/__init__.py +34 -0
  9. code_puppy/agents/pack/bloodhound.py +304 -0
  10. code_puppy/agents/pack/husky.py +321 -0
  11. code_puppy/agents/pack/retriever.py +393 -0
  12. code_puppy/agents/pack/shepherd.py +348 -0
  13. code_puppy/agents/pack/terrier.py +287 -0
  14. code_puppy/agents/pack/watchdog.py +367 -0
  15. code_puppy/agents/subagent_stream_handler.py +276 -0
  16. code_puppy/api/__init__.py +13 -0
  17. code_puppy/api/app.py +169 -0
  18. code_puppy/api/main.py +21 -0
  19. code_puppy/api/pty_manager.py +446 -0
  20. code_puppy/api/routers/__init__.py +12 -0
  21. code_puppy/api/routers/agents.py +36 -0
  22. code_puppy/api/routers/commands.py +217 -0
  23. code_puppy/api/routers/config.py +74 -0
  24. code_puppy/api/routers/sessions.py +232 -0
  25. code_puppy/api/templates/terminal.html +361 -0
  26. code_puppy/api/websocket.py +154 -0
  27. code_puppy/callbacks.py +73 -0
  28. code_puppy/claude_cache_client.py +249 -34
  29. code_puppy/command_line/core_commands.py +85 -0
  30. code_puppy/config.py +66 -62
  31. code_puppy/messaging/__init__.py +15 -0
  32. code_puppy/messaging/messages.py +27 -0
  33. code_puppy/messaging/queue_console.py +1 -1
  34. code_puppy/messaging/rich_renderer.py +36 -1
  35. code_puppy/messaging/spinner/__init__.py +20 -2
  36. code_puppy/messaging/subagent_console.py +461 -0
  37. code_puppy/model_utils.py +54 -0
  38. code_puppy/plugins/antigravity_oauth/antigravity_model.py +90 -19
  39. code_puppy/plugins/antigravity_oauth/transport.py +1 -0
  40. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  41. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  42. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  43. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  44. code_puppy/status_display.py +6 -2
  45. code_puppy/tools/__init__.py +37 -1
  46. code_puppy/tools/agent_tools.py +83 -33
  47. code_puppy/tools/browser/__init__.py +37 -0
  48. code_puppy/tools/browser/browser_control.py +6 -6
  49. code_puppy/tools/browser/browser_interactions.py +21 -20
  50. code_puppy/tools/browser/browser_locators.py +9 -9
  51. code_puppy/tools/browser/browser_navigation.py +7 -7
  52. code_puppy/tools/browser/browser_screenshot.py +78 -140
  53. code_puppy/tools/browser/browser_scripts.py +15 -13
  54. code_puppy/tools/browser/camoufox_manager.py +226 -64
  55. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  56. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  57. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  58. code_puppy/tools/browser/terminal_tools.py +525 -0
  59. code_puppy/tools/command_runner.py +292 -101
  60. code_puppy/tools/common.py +176 -1
  61. code_puppy/tools/display.py +84 -0
  62. code_puppy/tools/subagent_context.py +158 -0
  63. {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/METADATA +13 -11
  64. {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/RECORD +69 -38
  65. code_puppy/tools/browser/vqa_agent.py +0 -90
  66. {code_puppy-0.0.348.data → code_puppy-0.0.361.data}/data/code_puppy/models.json +0 -0
  67. {code_puppy-0.0.348.data → code_puppy-0.0.361.data}/data/code_puppy/models_dev_api.json +0 -0
  68. {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/WHEEL +0 -0
  69. {code_puppy-0.0.348.dist-info → code_puppy-0.0.361.dist-info}/entry_points.txt +0 -0
  70. {code_puppy-0.0.348.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
- """Singleton browser manager for Camoufox (privacy-focused Firefox) automation."""
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 __new__(cls):
22
- if cls._instance is None:
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
- def __init__(self):
27
- # Only initialize once
28
- if hasattr(self, "_init_done"):
29
- return
30
- self._init_done = True
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
- # Persistent profile directory for consistent browser state across runs
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 persistent profile directory (uses XDG_CACHE_HOME).
104
+ """Get or create the profile directory for this session.
53
105
 
54
- Returns a Path object pointing to XDG_CACHE_HOME/code_puppy/camoufox_profile
55
- where browser data (cookies, history, bookmarks, etc.) will be stored.
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
- profile_path = cache_dir / "camoufox_profile"
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 browser."""
118
+ """Initialize browser (Chromium or Camoufox based on browser_type)."""
64
119
  if self._initialized:
65
120
  return
66
121
 
67
122
  try:
68
- emit_info("Initializing Camoufox (privacy Firefox)...")
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
- # Ensure Camoufox binary and dependencies are fetched before launching
71
- await self._prefetch_camoufox()
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]✅ Camoufox initialized successfully (privacy-focused Firefox)[/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 Camoufox with the configured privacy settings."""
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 no disponible. Usando Playwright (Chromium) como alternativa."
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._context = context
117
- self._browser = context.browser
118
- self._initialized = True
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
- emit_success(f"Browser state saved to {storage_state_path}")
274
+ if not silent:
275
+ emit_success(f"Browser state saved to {storage_state_path}")
199
276
  except Exception as e:
200
- emit_warning(f"Could not save storage state: {e}")
277
+ if not silent:
278
+ emit_warning(f"Could not save storage state: {e}")
201
279
 
202
- await self._context.close()
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
- await self._browser.close()
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
- emit_warning(f"Warning during cleanup: {e}")
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
- loop = asyncio.get_event_loop()
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 # Best effort cleanup
357
+ pass # Silently ignore all errors during exit cleanup
230
358
 
231
359
 
232
- # Convenience function for getting the singleton instance
233
- def get_camoufox_manager() -> CamoufoxManager:
234
- """Get the singleton CamoufoxManager instance."""
235
- return CamoufoxManager.get_instance()
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]