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.
- code_puppy/agents/__init__.py +8 -0
- code_puppy/agents/agent_manager.py +272 -1
- 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 +11 -8
- code_puppy/agents/event_stream_handler.py +101 -8
- 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/chatgpt_codex_client.py +53 -0
- code_puppy/claude_cache_client.py +294 -41
- code_puppy/command_line/add_model_menu.py +13 -4
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/core_commands.py +89 -112
- code_puppy/command_line/model_picker_completion.py +3 -20
- code_puppy/command_line/model_settings_menu.py +21 -3
- code_puppy/config.py +145 -70
- code_puppy/gemini_model.py +706 -0
- code_puppy/http_utils.py +6 -3
- code_puppy/messaging/__init__.py +15 -0
- 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_factory.py +50 -16
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +27 -24
- code_puppy/models.json +12 -12
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +206 -172
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
- code_puppy/plugins/antigravity_oauth/transport.py +236 -45
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
- code_puppy/plugins/claude_code_oauth/utils.py +4 -1
- 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/pydantic_patches.py +52 -0
- code_puppy/status_display.py +6 -2
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +83 -33
- 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_manager.py +316 -0
- 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/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.348.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/METADATA +17 -16
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/RECORD +84 -51
- code_puppy/prompts/codex_system_prompt.md +0 -310
- code_puppy/tools/browser/camoufox_manager.py +0 -235
- code_puppy/tools/browser/vqa_agent.py +0 -90
- {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
- {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 .
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 .
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 .
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 .
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
142
|
+
browser_manager = get_session_browser_manager()
|
|
143
143
|
page = await browser_manager.get_current_page()
|
|
144
144
|
|
|
145
145
|
if not page:
|