code-puppy 0.0.348__py3-none-any.whl → 0.0.372__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 (87) hide show
  1. code_puppy/agents/__init__.py +8 -0
  2. code_puppy/agents/agent_manager.py +272 -1
  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 +11 -8
  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/chatgpt_codex_client.py +53 -0
  29. code_puppy/claude_cache_client.py +294 -41
  30. code_puppy/command_line/add_model_menu.py +13 -4
  31. code_puppy/command_line/agent_menu.py +662 -0
  32. code_puppy/command_line/core_commands.py +89 -112
  33. code_puppy/command_line/model_picker_completion.py +3 -20
  34. code_puppy/command_line/model_settings_menu.py +21 -3
  35. code_puppy/config.py +145 -70
  36. code_puppy/gemini_model.py +706 -0
  37. code_puppy/http_utils.py +6 -3
  38. code_puppy/messaging/__init__.py +15 -0
  39. code_puppy/messaging/messages.py +27 -0
  40. code_puppy/messaging/queue_console.py +1 -1
  41. code_puppy/messaging/rich_renderer.py +36 -1
  42. code_puppy/messaging/spinner/__init__.py +20 -2
  43. code_puppy/messaging/subagent_console.py +461 -0
  44. code_puppy/model_factory.py +50 -16
  45. code_puppy/model_switching.py +63 -0
  46. code_puppy/model_utils.py +27 -24
  47. code_puppy/models.json +12 -12
  48. code_puppy/plugins/antigravity_oauth/antigravity_model.py +206 -172
  49. code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
  50. code_puppy/plugins/antigravity_oauth/transport.py +236 -45
  51. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
  52. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
  53. code_puppy/plugins/claude_code_oauth/utils.py +4 -1
  54. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  55. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  56. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  57. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  58. code_puppy/pydantic_patches.py +52 -0
  59. code_puppy/status_display.py +6 -2
  60. code_puppy/tools/__init__.py +37 -1
  61. code_puppy/tools/agent_tools.py +83 -33
  62. code_puppy/tools/browser/__init__.py +37 -0
  63. code_puppy/tools/browser/browser_control.py +6 -6
  64. code_puppy/tools/browser/browser_interactions.py +21 -20
  65. code_puppy/tools/browser/browser_locators.py +9 -9
  66. code_puppy/tools/browser/browser_manager.py +316 -0
  67. code_puppy/tools/browser/browser_navigation.py +7 -7
  68. code_puppy/tools/browser/browser_screenshot.py +78 -140
  69. code_puppy/tools/browser/browser_scripts.py +15 -13
  70. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  71. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  72. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  73. code_puppy/tools/browser/terminal_tools.py +525 -0
  74. code_puppy/tools/command_runner.py +292 -101
  75. code_puppy/tools/common.py +176 -1
  76. code_puppy/tools/display.py +84 -0
  77. code_puppy/tools/subagent_context.py +158 -0
  78. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
  79. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/METADATA +17 -16
  80. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/RECORD +84 -51
  81. code_puppy/prompts/codex_system_prompt.md +0 -310
  82. code_puppy/tools/browser/camoufox_manager.py +0 -235
  83. code_puppy/tools/browser/vqa_agent.py +0 -90
  84. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
  85. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
  86. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
  87. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,7 @@ from pydantic_ai import RunContext
7
7
  from code_puppy.messaging import emit_error, emit_info, emit_success, emit_warning
8
8
  from code_puppy.tools.common import generate_group_id
9
9
 
10
- from .camoufox_manager import get_camoufox_manager
10
+ from .browser_manager import get_session_browser_manager
11
11
 
12
12
 
13
13
  async def initialize_browser(
@@ -22,7 +22,7 @@ async def initialize_browser(
22
22
  message_group=group_id,
23
23
  )
24
24
  try:
25
- browser_manager = get_camoufox_manager()
25
+ browser_manager = get_session_browser_manager()
26
26
 
27
27
  # Configure browser settings
28
28
  browser_manager.headless = headless
@@ -75,7 +75,7 @@ async def close_browser() -> Dict[str, Any]:
75
75
  message_group=group_id,
76
76
  )
77
77
  try:
78
- browser_manager = get_camoufox_manager()
78
+ browser_manager = get_session_browser_manager()
79
79
  await browser_manager.close()
80
80
 
81
81
  emit_warning("Browser closed successfully", message_group=group_id)
@@ -94,7 +94,7 @@ async def get_browser_status() -> Dict[str, Any]:
94
94
  message_group=group_id,
95
95
  )
96
96
  try:
97
- browser_manager = get_camoufox_manager()
97
+ browser_manager = get_session_browser_manager()
98
98
 
99
99
  if not browser_manager._initialized:
100
100
  return {
@@ -139,7 +139,7 @@ async def create_new_page(url: Optional[str] = None) -> Dict[str, Any]:
139
139
  message_group=group_id,
140
140
  )
141
141
  try:
142
- browser_manager = get_camoufox_manager()
142
+ browser_manager = get_session_browser_manager()
143
143
 
144
144
  if not browser_manager._initialized:
145
145
  return {
@@ -168,7 +168,7 @@ async def list_pages() -> Dict[str, Any]:
168
168
  message_group=group_id,
169
169
  )
170
170
  try:
171
- browser_manager = get_camoufox_manager()
171
+ browser_manager = get_session_browser_manager()
172
172
 
173
173
  if not browser_manager._initialized:
174
174
  return {"success": False, "error": "Browser not initialized"}
@@ -7,7 +7,7 @@ from pydantic_ai import RunContext
7
7
  from code_puppy.messaging import emit_error, emit_info, emit_success
8
8
  from code_puppy.tools.common import generate_group_id
9
9
 
10
- from .camoufox_manager import get_camoufox_manager
10
+ from .browser_manager import get_session_browser_manager
11
11
 
12
12
 
13
13
  async def click_element(
@@ -24,14 +24,15 @@ async def click_element(
24
24
  message_group=group_id,
25
25
  )
26
26
  try:
27
- browser_manager = get_camoufox_manager()
27
+ browser_manager = get_session_browser_manager()
28
28
  page = await browser_manager.get_current_page()
29
29
 
30
30
  if not page:
31
31
  return {"success": False, "error": "No active browser page available"}
32
32
 
33
- # Find element
34
- element = page.locator(selector)
33
+ # Find element - use .first to handle cases where selector matches multiple elements
34
+ # This avoids Playwright's strict mode violation errors
35
+ element = page.locator(selector).first
35
36
 
36
37
  # Wait for element to be visible and enabled
37
38
  await element.wait_for(state="visible", timeout=timeout)
@@ -69,13 +70,13 @@ async def double_click_element(
69
70
  message_group=group_id,
70
71
  )
71
72
  try:
72
- browser_manager = get_camoufox_manager()
73
+ browser_manager = get_session_browser_manager()
73
74
  page = await browser_manager.get_current_page()
74
75
 
75
76
  if not page:
76
77
  return {"success": False, "error": "No active browser page available"}
77
78
 
78
- element = page.locator(selector)
79
+ element = page.locator(selector).first
79
80
  await element.wait_for(state="visible", timeout=timeout)
80
81
  await element.dblclick(force=force, timeout=timeout)
81
82
 
@@ -99,13 +100,13 @@ async def hover_element(
99
100
  message_group=group_id,
100
101
  )
101
102
  try:
102
- browser_manager = get_camoufox_manager()
103
+ browser_manager = get_session_browser_manager()
103
104
  page = await browser_manager.get_current_page()
104
105
 
105
106
  if not page:
106
107
  return {"success": False, "error": "No active browser page available"}
107
108
 
108
- element = page.locator(selector)
109
+ element = page.locator(selector).first
109
110
  await element.wait_for(state="visible", timeout=timeout)
110
111
  await element.hover(force=force, timeout=timeout)
111
112
 
@@ -130,13 +131,13 @@ async def set_element_text(
130
131
  message_group=group_id,
131
132
  )
132
133
  try:
133
- browser_manager = get_camoufox_manager()
134
+ browser_manager = get_session_browser_manager()
134
135
  page = await browser_manager.get_current_page()
135
136
 
136
137
  if not page:
137
138
  return {"success": False, "error": "No active browser page available"}
138
139
 
139
- element = page.locator(selector)
140
+ element = page.locator(selector).first
140
141
  await element.wait_for(state="visible", timeout=timeout)
141
142
 
142
143
  if clear_first:
@@ -169,13 +170,13 @@ async def get_element_text(
169
170
  message_group=group_id,
170
171
  )
171
172
  try:
172
- browser_manager = get_camoufox_manager()
173
+ browser_manager = get_session_browser_manager()
173
174
  page = await browser_manager.get_current_page()
174
175
 
175
176
  if not page:
176
177
  return {"success": False, "error": "No active browser page available"}
177
178
 
178
- element = page.locator(selector)
179
+ element = page.locator(selector).first
179
180
  await element.wait_for(state="visible", timeout=timeout)
180
181
 
181
182
  text = await element.text_content()
@@ -197,13 +198,13 @@ async def get_element_value(
197
198
  message_group=group_id,
198
199
  )
199
200
  try:
200
- browser_manager = get_camoufox_manager()
201
+ browser_manager = get_session_browser_manager()
201
202
  page = await browser_manager.get_current_page()
202
203
 
203
204
  if not page:
204
205
  return {"success": False, "error": "No active browser page available"}
205
206
 
206
- element = page.locator(selector)
207
+ element = page.locator(selector).first
207
208
  await element.wait_for(state="visible", timeout=timeout)
208
209
 
209
210
  value = await element.input_value()
@@ -231,13 +232,13 @@ async def select_option(
231
232
  message_group=group_id,
232
233
  )
233
234
  try:
234
- browser_manager = get_camoufox_manager()
235
+ browser_manager = get_session_browser_manager()
235
236
  page = await browser_manager.get_current_page()
236
237
 
237
238
  if not page:
238
239
  return {"success": False, "error": "No active browser page available"}
239
240
 
240
- element = page.locator(selector)
241
+ element = page.locator(selector).first
241
242
  await element.wait_for(state="visible", timeout=timeout)
242
243
 
243
244
  if value is not None:
@@ -278,13 +279,13 @@ async def check_element(
278
279
  message_group=group_id,
279
280
  )
280
281
  try:
281
- browser_manager = get_camoufox_manager()
282
+ browser_manager = get_session_browser_manager()
282
283
  page = await browser_manager.get_current_page()
283
284
 
284
285
  if not page:
285
286
  return {"success": False, "error": "No active browser page available"}
286
287
 
287
- element = page.locator(selector)
288
+ element = page.locator(selector).first
288
289
  await element.wait_for(state="visible", timeout=timeout)
289
290
  await element.check(timeout=timeout)
290
291
 
@@ -307,13 +308,13 @@ async def uncheck_element(
307
308
  message_group=group_id,
308
309
  )
309
310
  try:
310
- browser_manager = get_camoufox_manager()
311
+ browser_manager = get_session_browser_manager()
311
312
  page = await browser_manager.get_current_page()
312
313
 
313
314
  if not page:
314
315
  return {"success": False, "error": "No active browser page available"}
315
316
 
316
- element = page.locator(selector)
317
+ element = page.locator(selector).first
317
318
  await element.wait_for(state="visible", timeout=timeout)
318
319
  await element.uncheck(timeout=timeout)
319
320
 
@@ -7,7 +7,7 @@ from pydantic_ai import RunContext
7
7
  from code_puppy.messaging import emit_info, emit_success
8
8
  from code_puppy.tools.common import generate_group_id
9
9
 
10
- from .camoufox_manager import get_camoufox_manager
10
+ from .browser_manager import get_session_browser_manager
11
11
 
12
12
 
13
13
  async def find_by_role(
@@ -23,7 +23,7 @@ async def find_by_role(
23
23
  message_group=group_id,
24
24
  )
25
25
  try:
26
- browser_manager = get_camoufox_manager()
26
+ browser_manager = get_session_browser_manager()
27
27
  page = await browser_manager.get_current_page()
28
28
 
29
29
  if not page:
@@ -75,7 +75,7 @@ async def find_by_text(
75
75
  message_group=group_id,
76
76
  )
77
77
  try:
78
- browser_manager = get_camoufox_manager()
78
+ browser_manager = get_session_browser_manager()
79
79
  page = await browser_manager.get_current_page()
80
80
 
81
81
  if not page:
@@ -127,7 +127,7 @@ async def find_by_label(
127
127
  message_group=group_id,
128
128
  )
129
129
  try:
130
- browser_manager = get_camoufox_manager()
130
+ browser_manager = get_session_browser_manager()
131
131
  page = await browser_manager.get_current_page()
132
132
 
133
133
  if not page:
@@ -190,7 +190,7 @@ async def find_by_placeholder(
190
190
  message_group=group_id,
191
191
  )
192
192
  try:
193
- browser_manager = get_camoufox_manager()
193
+ browser_manager = get_session_browser_manager()
194
194
  page = await browser_manager.get_current_page()
195
195
 
196
196
  if not page:
@@ -248,7 +248,7 @@ async def find_by_test_id(
248
248
  message_group=group_id,
249
249
  )
250
250
  try:
251
- browser_manager = get_camoufox_manager()
251
+ browser_manager = get_session_browser_manager()
252
252
  page = await browser_manager.get_current_page()
253
253
 
254
254
  if not page:
@@ -304,7 +304,7 @@ async def run_xpath_query(
304
304
  message_group=group_id,
305
305
  )
306
306
  try:
307
- browser_manager = get_camoufox_manager()
307
+ browser_manager = get_session_browser_manager()
308
308
  page = await browser_manager.get_current_page()
309
309
 
310
310
  if not page:
@@ -359,7 +359,7 @@ async def find_buttons(
359
359
  message_group=group_id,
360
360
  )
361
361
  try:
362
- browser_manager = get_camoufox_manager()
362
+ browser_manager = get_session_browser_manager()
363
363
  page = await browser_manager.get_current_page()
364
364
 
365
365
  if not page:
@@ -410,7 +410,7 @@ async def find_links(
410
410
  message_group=group_id,
411
411
  )
412
412
  try:
413
- browser_manager = get_camoufox_manager()
413
+ browser_manager = get_session_browser_manager()
414
414
  page = await browser_manager.get_current_page()
415
415
 
416
416
  if not page:
@@ -0,0 +1,316 @@
1
+ """Playwright browser manager for browser automation.
2
+
3
+ Supports multiple simultaneous instances with unique profile directories.
4
+ """
5
+
6
+ import asyncio
7
+ import atexit
8
+ import contextvars
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from playwright.async_api import Browser, BrowserContext, Page
14
+
15
+ from code_puppy import config
16
+ from code_puppy.messaging import emit_info, emit_success, emit_warning
17
+
18
+ # Store active manager instances by session ID
19
+ _active_managers: dict[str, "BrowserManager"] = {}
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() -> "BrowserManager":
53
+ """Get the BrowserManager 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 BrowserManager instance for the current session.
61
+ """
62
+ session_id = get_browser_session()
63
+ return get_browser_manager(session_id)
64
+
65
+
66
+ # Flag to track if cleanup has already run
67
+ _cleanup_done: bool = False
68
+
69
+
70
+ class BrowserManager:
71
+ """Browser manager for Playwright-based browser automation.
72
+
73
+ Supports multiple simultaneous instances, each with its own profile directory.
74
+ Uses Chromium by default for maximum compatibility.
75
+ """
76
+
77
+ _browser: Optional[Browser] = None
78
+ _context: Optional[BrowserContext] = None
79
+ _initialized: bool = False
80
+
81
+ def __init__(self, session_id: Optional[str] = None):
82
+ """Initialize manager settings.
83
+
84
+ Args:
85
+ session_id: Optional session ID for this instance.
86
+ If None, uses 'default' as the session ID.
87
+ """
88
+ self.session_id = session_id or "default"
89
+
90
+ # Default to headless=True (no browser spam during tests)
91
+ # Override with BROWSER_HEADLESS=false to see the browser
92
+ self.headless = os.getenv("BROWSER_HEADLESS", "true").lower() != "false"
93
+ self.homepage = "https://www.google.com"
94
+
95
+ # Unique profile directory per session for browser state
96
+ self.profile_dir = self._get_profile_directory()
97
+
98
+ def _get_profile_directory(self) -> Path:
99
+ """Get or create the profile directory for this session.
100
+
101
+ Each session gets its own profile directory under:
102
+ XDG_CACHE_HOME/code_puppy/browser_profiles/<session_id>/
103
+
104
+ This allows multiple instances to run simultaneously.
105
+ """
106
+ cache_dir = Path(config.CACHE_DIR)
107
+ profiles_base = cache_dir / "browser_profiles"
108
+ profile_path = profiles_base / self.session_id
109
+ profile_path.mkdir(parents=True, exist_ok=True, mode=0o700)
110
+ return profile_path
111
+
112
+ async def async_initialize(self) -> None:
113
+ """Initialize Chromium browser via Playwright."""
114
+ if self._initialized:
115
+ return
116
+
117
+ try:
118
+ emit_info(f"Initializing Chromium browser (session: {self.session_id})...")
119
+ await self._initialize_browser()
120
+ self._initialized = True
121
+
122
+ except Exception:
123
+ await self._cleanup()
124
+ raise
125
+
126
+ async def _initialize_browser(self) -> None:
127
+ """Initialize Playwright Chromium browser with persistent context."""
128
+ from playwright.async_api import async_playwright
129
+
130
+ emit_info(f"Using persistent profile: {self.profile_dir}")
131
+
132
+ pw = await async_playwright().start()
133
+ # Use persistent context directory for Chromium to preserve browser state
134
+ context = await pw.chromium.launch_persistent_context(
135
+ user_data_dir=str(self.profile_dir), headless=self.headless
136
+ )
137
+ self._context = context
138
+ self._browser = context.browser
139
+ self._initialized = True
140
+
141
+ async def get_current_page(self) -> Optional[Page]:
142
+ """Get the currently active page. Lazily creates one if none exist."""
143
+ if not self._initialized or not self._context:
144
+ await self.async_initialize()
145
+
146
+ if not self._context:
147
+ return None
148
+
149
+ pages = self._context.pages
150
+ if pages:
151
+ return pages[0]
152
+
153
+ # Lazily create a new blank page without navigation
154
+ return await self._context.new_page()
155
+
156
+ async def new_page(self, url: Optional[str] = None) -> Page:
157
+ """Create a new page and optionally navigate to URL."""
158
+ if not self._initialized:
159
+ await self.async_initialize()
160
+
161
+ page = await self._context.new_page()
162
+ if url:
163
+ await page.goto(url)
164
+ return page
165
+
166
+ async def close_page(self, page: Page) -> None:
167
+ """Close a specific page."""
168
+ await page.close()
169
+
170
+ async def get_all_pages(self) -> list[Page]:
171
+ """Get all open pages."""
172
+ if not self._context:
173
+ return []
174
+ return self._context.pages
175
+
176
+ async def _cleanup(self, silent: bool = False) -> None:
177
+ """Clean up browser resources and save persistent state.
178
+
179
+ Args:
180
+ silent: If True, suppress all errors (used during shutdown).
181
+ """
182
+ try:
183
+ # Save browser state before closing (cookies, localStorage, etc.)
184
+ if self._context:
185
+ try:
186
+ storage_state_path = self.profile_dir / "storage_state.json"
187
+ await self._context.storage_state(path=str(storage_state_path))
188
+ if not silent:
189
+ emit_success(f"Browser state saved to {storage_state_path}")
190
+ except Exception as e:
191
+ if not silent:
192
+ emit_warning(f"Could not save storage state: {e}")
193
+
194
+ try:
195
+ await self._context.close()
196
+ except Exception:
197
+ pass # Ignore errors during context close
198
+ self._context = None
199
+
200
+ if self._browser:
201
+ try:
202
+ await self._browser.close()
203
+ except Exception:
204
+ pass # Ignore errors during browser close
205
+ self._browser = None
206
+
207
+ self._initialized = False
208
+
209
+ # Remove from active managers
210
+ if self.session_id in _active_managers:
211
+ del _active_managers[self.session_id]
212
+
213
+ except Exception as e:
214
+ if not silent:
215
+ emit_warning(f"Warning during cleanup: {e}")
216
+
217
+ async def close(self) -> None:
218
+ """Close the browser and clean up resources."""
219
+ await self._cleanup()
220
+ emit_info(f"Browser closed (session: {self.session_id})")
221
+
222
+
223
+ def get_browser_manager(session_id: Optional[str] = None) -> BrowserManager:
224
+ """Get or create a BrowserManager instance.
225
+
226
+ Args:
227
+ session_id: Optional session ID. If provided and a manager with this
228
+ session exists, returns that manager. Otherwise creates a new one.
229
+ If None, uses 'default' as the session ID.
230
+
231
+ Returns:
232
+ A BrowserManager instance.
233
+
234
+ Example:
235
+ # Default session (for single-agent use)
236
+ manager = get_browser_manager()
237
+
238
+ # Named session (for multi-agent use)
239
+ manager = get_browser_manager("qa-agent-1")
240
+ """
241
+ session_id = session_id or "default"
242
+
243
+ if session_id not in _active_managers:
244
+ _active_managers[session_id] = BrowserManager(session_id)
245
+
246
+ return _active_managers[session_id]
247
+
248
+
249
+ async def cleanup_all_browsers() -> None:
250
+ """Close all active browser manager instances.
251
+
252
+ This should be called before application exit to ensure all browser
253
+ connections are properly closed and no dangling futures remain.
254
+ """
255
+ global _cleanup_done
256
+
257
+ if _cleanup_done:
258
+ return
259
+
260
+ _cleanup_done = True
261
+
262
+ # Get a copy of the keys since we'll be modifying the dict during cleanup
263
+ session_ids = list(_active_managers.keys())
264
+
265
+ for session_id in session_ids:
266
+ manager = _active_managers.get(session_id)
267
+ if manager and manager._initialized:
268
+ try:
269
+ await manager._cleanup(silent=True)
270
+ except Exception:
271
+ pass # Silently ignore all errors during exit cleanup
272
+
273
+
274
+ def _sync_cleanup_browsers() -> None:
275
+ """Synchronous cleanup wrapper for use with atexit.
276
+
277
+ Creates a new event loop to run the async cleanup since the main
278
+ event loop may have already been closed when atexit handlers run.
279
+ """
280
+ global _cleanup_done
281
+
282
+ if _cleanup_done or not _active_managers:
283
+ return
284
+
285
+ try:
286
+ # Try to get the running loop first
287
+ try:
288
+ loop = asyncio.get_running_loop()
289
+ # If we're in an async context, schedule the cleanup
290
+ # but this is unlikely in atexit handlers
291
+ loop.create_task(cleanup_all_browsers())
292
+ return
293
+ except RuntimeError:
294
+ pass # No running loop, which is expected in atexit
295
+
296
+ # Create a new event loop for cleanup
297
+ loop = asyncio.new_event_loop()
298
+ asyncio.set_event_loop(loop)
299
+ try:
300
+ loop.run_until_complete(cleanup_all_browsers())
301
+ finally:
302
+ loop.close()
303
+ except Exception:
304
+ # Silently swallow ALL errors during exit cleanup
305
+ # We don't want to spam the user with errors on exit
306
+ pass
307
+
308
+
309
+ # Register the cleanup handler with atexit
310
+ # This ensures browsers are closed even if close_browser() isn't explicitly called
311
+ atexit.register(_sync_cleanup_browsers)
312
+
313
+
314
+ # Backwards compatibility aliases
315
+ CamoufoxManager = BrowserManager
316
+ get_camoufox_manager = get_browser_manager
@@ -7,7 +7,7 @@ from pydantic_ai import RunContext
7
7
  from code_puppy.messaging import emit_error, emit_info, emit_success
8
8
  from code_puppy.tools.common import generate_group_id
9
9
 
10
- from .camoufox_manager import get_camoufox_manager
10
+ from .browser_manager import get_session_browser_manager
11
11
 
12
12
 
13
13
  async def navigate_to_url(url: str) -> Dict[str, Any]:
@@ -18,7 +18,7 @@ async def navigate_to_url(url: str) -> Dict[str, Any]:
18
18
  message_group=group_id,
19
19
  )
20
20
  try:
21
- browser_manager = get_camoufox_manager()
21
+ browser_manager = get_session_browser_manager()
22
22
  page = await browser_manager.get_current_page()
23
23
 
24
24
  if not page:
@@ -48,7 +48,7 @@ async def get_page_info() -> Dict[str, Any]:
48
48
  message_group=group_id,
49
49
  )
50
50
  try:
51
- browser_manager = get_camoufox_manager()
51
+ browser_manager = get_session_browser_manager()
52
52
  page = await browser_manager.get_current_page()
53
53
 
54
54
  if not page:
@@ -71,7 +71,7 @@ async def go_back() -> Dict[str, Any]:
71
71
  message_group=group_id,
72
72
  )
73
73
  try:
74
- browser_manager = get_camoufox_manager()
74
+ browser_manager = get_session_browser_manager()
75
75
  page = await browser_manager.get_current_page()
76
76
 
77
77
  if not page:
@@ -93,7 +93,7 @@ async def go_forward() -> Dict[str, Any]:
93
93
  message_group=group_id,
94
94
  )
95
95
  try:
96
- browser_manager = get_camoufox_manager()
96
+ browser_manager = get_session_browser_manager()
97
97
  page = await browser_manager.get_current_page()
98
98
 
99
99
  if not page:
@@ -115,7 +115,7 @@ async def reload_page(wait_until: str = "domcontentloaded") -> Dict[str, Any]:
115
115
  message_group=group_id,
116
116
  )
117
117
  try:
118
- browser_manager = get_camoufox_manager()
118
+ browser_manager = get_session_browser_manager()
119
119
  page = await browser_manager.get_current_page()
120
120
 
121
121
  if not page:
@@ -139,7 +139,7 @@ async def wait_for_load_state(
139
139
  message_group=group_id,
140
140
  )
141
141
  try:
142
- browser_manager = get_camoufox_manager()
142
+ browser_manager = get_session_browser_manager()
143
143
  page = await browser_manager.get_current_page()
144
144
 
145
145
  if not page: