camel-ai 0.2.67__py3-none-any.whl → 0.2.80a2__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 (224) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/_types.py +6 -2
  3. camel/agents/_utils.py +38 -0
  4. camel/agents/chat_agent.py +4014 -410
  5. camel/agents/mcp_agent.py +30 -27
  6. camel/agents/repo_agent.py +2 -1
  7. camel/benchmarks/browsecomp.py +6 -6
  8. camel/configs/__init__.py +15 -0
  9. camel/configs/aihubmix_config.py +88 -0
  10. camel/configs/amd_config.py +70 -0
  11. camel/configs/cometapi_config.py +104 -0
  12. camel/configs/minimax_config.py +93 -0
  13. camel/configs/nebius_config.py +103 -0
  14. camel/configs/vllm_config.py +2 -0
  15. camel/data_collectors/alpaca_collector.py +15 -6
  16. camel/datagen/self_improving_cot.py +1 -1
  17. camel/datasets/base_generator.py +39 -10
  18. camel/environments/__init__.py +12 -0
  19. camel/environments/rlcards_env.py +860 -0
  20. camel/environments/single_step.py +28 -3
  21. camel/environments/tic_tac_toe.py +1 -1
  22. camel/interpreters/__init__.py +2 -0
  23. camel/interpreters/docker/Dockerfile +4 -16
  24. camel/interpreters/docker_interpreter.py +3 -2
  25. camel/interpreters/e2b_interpreter.py +34 -1
  26. camel/interpreters/internal_python_interpreter.py +51 -2
  27. camel/interpreters/microsandbox_interpreter.py +395 -0
  28. camel/loaders/__init__.py +11 -2
  29. camel/loaders/base_loader.py +85 -0
  30. camel/loaders/chunkr_reader.py +9 -0
  31. camel/loaders/firecrawl_reader.py +4 -4
  32. camel/logger.py +1 -1
  33. camel/memories/agent_memories.py +84 -1
  34. camel/memories/base.py +34 -0
  35. camel/memories/blocks/chat_history_block.py +122 -4
  36. camel/memories/blocks/vectordb_block.py +8 -1
  37. camel/memories/context_creators/score_based.py +29 -237
  38. camel/memories/records.py +88 -8
  39. camel/messages/base.py +166 -40
  40. camel/messages/func_message.py +32 -5
  41. camel/models/__init__.py +10 -0
  42. camel/models/aihubmix_model.py +83 -0
  43. camel/models/aiml_model.py +1 -16
  44. camel/models/amd_model.py +101 -0
  45. camel/models/anthropic_model.py +117 -18
  46. camel/models/aws_bedrock_model.py +2 -33
  47. camel/models/azure_openai_model.py +205 -91
  48. camel/models/base_audio_model.py +3 -1
  49. camel/models/base_model.py +189 -24
  50. camel/models/cohere_model.py +5 -17
  51. camel/models/cometapi_model.py +83 -0
  52. camel/models/crynux_model.py +1 -16
  53. camel/models/deepseek_model.py +6 -16
  54. camel/models/fish_audio_model.py +6 -0
  55. camel/models/gemini_model.py +71 -20
  56. camel/models/groq_model.py +1 -17
  57. camel/models/internlm_model.py +1 -16
  58. camel/models/litellm_model.py +49 -32
  59. camel/models/lmstudio_model.py +1 -17
  60. camel/models/minimax_model.py +83 -0
  61. camel/models/mistral_model.py +1 -16
  62. camel/models/model_factory.py +27 -1
  63. camel/models/model_manager.py +24 -6
  64. camel/models/modelscope_model.py +1 -16
  65. camel/models/moonshot_model.py +185 -19
  66. camel/models/nebius_model.py +83 -0
  67. camel/models/nemotron_model.py +0 -5
  68. camel/models/netmind_model.py +1 -16
  69. camel/models/novita_model.py +1 -16
  70. camel/models/nvidia_model.py +1 -16
  71. camel/models/ollama_model.py +4 -19
  72. camel/models/openai_compatible_model.py +171 -46
  73. camel/models/openai_model.py +205 -77
  74. camel/models/openrouter_model.py +1 -17
  75. camel/models/ppio_model.py +1 -16
  76. camel/models/qianfan_model.py +1 -16
  77. camel/models/qwen_model.py +1 -16
  78. camel/models/reka_model.py +1 -16
  79. camel/models/samba_model.py +34 -47
  80. camel/models/sglang_model.py +64 -31
  81. camel/models/siliconflow_model.py +1 -16
  82. camel/models/stub_model.py +0 -4
  83. camel/models/togetherai_model.py +1 -16
  84. camel/models/vllm_model.py +1 -16
  85. camel/models/volcano_model.py +0 -17
  86. camel/models/watsonx_model.py +1 -16
  87. camel/models/yi_model.py +1 -16
  88. camel/models/zhipuai_model.py +60 -16
  89. camel/parsers/__init__.py +18 -0
  90. camel/parsers/mcp_tool_call_parser.py +176 -0
  91. camel/retrievers/auto_retriever.py +1 -0
  92. camel/runtimes/configs.py +11 -11
  93. camel/runtimes/daytona_runtime.py +15 -16
  94. camel/runtimes/docker_runtime.py +6 -6
  95. camel/runtimes/remote_http_runtime.py +5 -5
  96. camel/services/agent_openapi_server.py +380 -0
  97. camel/societies/__init__.py +2 -0
  98. camel/societies/role_playing.py +26 -28
  99. camel/societies/workforce/__init__.py +2 -0
  100. camel/societies/workforce/events.py +122 -0
  101. camel/societies/workforce/prompts.py +249 -38
  102. camel/societies/workforce/role_playing_worker.py +82 -20
  103. camel/societies/workforce/single_agent_worker.py +634 -34
  104. camel/societies/workforce/structured_output_handler.py +512 -0
  105. camel/societies/workforce/task_channel.py +169 -23
  106. camel/societies/workforce/utils.py +176 -9
  107. camel/societies/workforce/worker.py +77 -23
  108. camel/societies/workforce/workflow_memory_manager.py +772 -0
  109. camel/societies/workforce/workforce.py +3168 -478
  110. camel/societies/workforce/workforce_callback.py +74 -0
  111. camel/societies/workforce/workforce_logger.py +203 -175
  112. camel/societies/workforce/workforce_metrics.py +33 -0
  113. camel/storages/__init__.py +4 -0
  114. camel/storages/key_value_storages/json.py +15 -2
  115. camel/storages/key_value_storages/mem0_cloud.py +48 -47
  116. camel/storages/object_storages/google_cloud.py +1 -1
  117. camel/storages/vectordb_storages/__init__.py +6 -0
  118. camel/storages/vectordb_storages/chroma.py +731 -0
  119. camel/storages/vectordb_storages/oceanbase.py +13 -13
  120. camel/storages/vectordb_storages/pgvector.py +349 -0
  121. camel/storages/vectordb_storages/qdrant.py +3 -3
  122. camel/storages/vectordb_storages/surreal.py +365 -0
  123. camel/storages/vectordb_storages/tidb.py +8 -6
  124. camel/tasks/task.py +244 -27
  125. camel/toolkits/__init__.py +46 -8
  126. camel/toolkits/aci_toolkit.py +64 -19
  127. camel/toolkits/arxiv_toolkit.py +6 -6
  128. camel/toolkits/base.py +63 -5
  129. camel/toolkits/code_execution.py +28 -1
  130. camel/toolkits/context_summarizer_toolkit.py +684 -0
  131. camel/toolkits/craw4ai_toolkit.py +93 -0
  132. camel/toolkits/dappier_toolkit.py +10 -6
  133. camel/toolkits/dingtalk.py +1135 -0
  134. camel/toolkits/edgeone_pages_mcp_toolkit.py +49 -0
  135. camel/toolkits/excel_toolkit.py +901 -67
  136. camel/toolkits/file_toolkit.py +1402 -0
  137. camel/toolkits/function_tool.py +30 -6
  138. camel/toolkits/github_toolkit.py +107 -20
  139. camel/toolkits/gmail_toolkit.py +1839 -0
  140. camel/toolkits/google_calendar_toolkit.py +38 -4
  141. camel/toolkits/google_drive_mcp_toolkit.py +54 -0
  142. camel/toolkits/human_toolkit.py +34 -10
  143. camel/toolkits/hybrid_browser_toolkit/__init__.py +18 -0
  144. camel/toolkits/hybrid_browser_toolkit/config_loader.py +185 -0
  145. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +246 -0
  146. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +1973 -0
  147. camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
  148. camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +3749 -0
  149. camel/toolkits/hybrid_browser_toolkit/ts/package.json +32 -0
  150. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-scripts.js +125 -0
  151. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +1815 -0
  152. camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +233 -0
  153. camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +590 -0
  154. camel/toolkits/hybrid_browser_toolkit/ts/src/index.ts +7 -0
  155. camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
  156. camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
  157. camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
  158. camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +130 -0
  159. camel/toolkits/hybrid_browser_toolkit/ts/tsconfig.json +26 -0
  160. camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +319 -0
  161. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +1032 -0
  162. camel/toolkits/hybrid_browser_toolkit_py/__init__.py +17 -0
  163. camel/toolkits/hybrid_browser_toolkit_py/actions.py +575 -0
  164. camel/toolkits/hybrid_browser_toolkit_py/agent.py +311 -0
  165. camel/toolkits/hybrid_browser_toolkit_py/browser_session.py +787 -0
  166. camel/toolkits/hybrid_browser_toolkit_py/config_loader.py +490 -0
  167. camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +2390 -0
  168. camel/toolkits/hybrid_browser_toolkit_py/snapshot.py +233 -0
  169. camel/toolkits/hybrid_browser_toolkit_py/stealth_script.js +0 -0
  170. camel/toolkits/hybrid_browser_toolkit_py/unified_analyzer.js +1043 -0
  171. camel/toolkits/image_generation_toolkit.py +390 -0
  172. camel/toolkits/jina_reranker_toolkit.py +3 -4
  173. camel/toolkits/klavis_toolkit.py +5 -1
  174. camel/toolkits/markitdown_toolkit.py +104 -0
  175. camel/toolkits/math_toolkit.py +64 -10
  176. camel/toolkits/mcp_toolkit.py +370 -45
  177. camel/toolkits/memory_toolkit.py +5 -1
  178. camel/toolkits/message_agent_toolkit.py +608 -0
  179. camel/toolkits/message_integration.py +724 -0
  180. camel/toolkits/minimax_mcp_toolkit.py +195 -0
  181. camel/toolkits/note_taking_toolkit.py +277 -0
  182. camel/toolkits/notion_mcp_toolkit.py +224 -0
  183. camel/toolkits/openbb_toolkit.py +5 -1
  184. camel/toolkits/origene_mcp_toolkit.py +56 -0
  185. camel/toolkits/playwright_mcp_toolkit.py +12 -31
  186. camel/toolkits/pptx_toolkit.py +25 -12
  187. camel/toolkits/resend_toolkit.py +168 -0
  188. camel/toolkits/screenshot_toolkit.py +213 -0
  189. camel/toolkits/search_toolkit.py +437 -142
  190. camel/toolkits/slack_toolkit.py +104 -50
  191. camel/toolkits/sympy_toolkit.py +1 -1
  192. camel/toolkits/task_planning_toolkit.py +3 -3
  193. camel/toolkits/terminal_toolkit/__init__.py +18 -0
  194. camel/toolkits/terminal_toolkit/terminal_toolkit.py +957 -0
  195. camel/toolkits/terminal_toolkit/utils.py +532 -0
  196. camel/toolkits/thinking_toolkit.py +1 -1
  197. camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
  198. camel/toolkits/video_analysis_toolkit.py +106 -26
  199. camel/toolkits/video_download_toolkit.py +17 -14
  200. camel/toolkits/web_deploy_toolkit.py +1219 -0
  201. camel/toolkits/wechat_official_toolkit.py +483 -0
  202. camel/toolkits/zapier_toolkit.py +5 -1
  203. camel/types/__init__.py +2 -2
  204. camel/types/agents/tool_calling_record.py +4 -1
  205. camel/types/enums.py +316 -40
  206. camel/types/openai_types.py +2 -2
  207. camel/types/unified_model_type.py +31 -4
  208. camel/utils/commons.py +36 -5
  209. camel/utils/constants.py +3 -0
  210. camel/utils/context_utils.py +1003 -0
  211. camel/utils/mcp.py +138 -4
  212. camel/utils/mcp_client.py +45 -1
  213. camel/utils/message_summarizer.py +148 -0
  214. camel/utils/token_counting.py +43 -20
  215. camel/utils/tool_result.py +44 -0
  216. {camel_ai-0.2.67.dist-info → camel_ai-0.2.80a2.dist-info}/METADATA +296 -85
  217. {camel_ai-0.2.67.dist-info → camel_ai-0.2.80a2.dist-info}/RECORD +219 -146
  218. camel/loaders/pandas_reader.py +0 -368
  219. camel/toolkits/dalle_toolkit.py +0 -175
  220. camel/toolkits/file_write_toolkit.py +0 -444
  221. camel/toolkits/openai_agent_toolkit.py +0 -135
  222. camel/toolkits/terminal_toolkit.py +0 -1037
  223. {camel_ai-0.2.67.dist-info → camel_ai-0.2.80a2.dist-info}/WHEEL +0 -0
  224. {camel_ai-0.2.67.dist-info → camel_ai-0.2.80a2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1032 @@
1
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
+
15
+ import asyncio
16
+ import contextlib
17
+ import datetime
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import time
22
+ import uuid
23
+ from contextvars import ContextVar
24
+ from functools import wraps
25
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional
26
+
27
+ if TYPE_CHECKING:
28
+ import websockets
29
+ else:
30
+ try:
31
+ import websockets
32
+ except ImportError:
33
+ websockets = None
34
+
35
+ from camel.logger import get_logger
36
+ from camel.utils.tool_result import ToolResult
37
+
38
+ from .installer import check_and_install_dependencies
39
+
40
+ logger = get_logger(__name__)
41
+
42
+ # Context variable to track if we're inside a high-level action
43
+ _in_high_level_action: ContextVar[bool] = ContextVar(
44
+ '_in_high_level_action', default=False
45
+ )
46
+
47
+
48
+ def _create_memory_aware_error(base_msg: str) -> str:
49
+ import psutil
50
+
51
+ mem = psutil.virtual_memory()
52
+ if mem.available < 1024**3:
53
+ return (
54
+ f"{base_msg} "
55
+ f"(likely due to insufficient memory). "
56
+ f"Available memory: {mem.available / 1024**3:.2f}GB "
57
+ f"({mem.percent}% used)"
58
+ )
59
+ return base_msg
60
+
61
+
62
+ async def _cleanup_process_and_tasks(process, log_reader_task, ts_log_file):
63
+ if process:
64
+ with contextlib.suppress(ProcessLookupError, Exception):
65
+ process.kill()
66
+ with contextlib.suppress(Exception):
67
+ process.wait(timeout=2)
68
+
69
+ if log_reader_task and not log_reader_task.done():
70
+ log_reader_task.cancel()
71
+ with contextlib.suppress(asyncio.CancelledError):
72
+ await log_reader_task
73
+
74
+ if ts_log_file:
75
+ with contextlib.suppress(Exception):
76
+ ts_log_file.close()
77
+
78
+
79
+ def action_logger(func):
80
+ """Decorator to add logging to action methods.
81
+
82
+ Skips logging if already inside a high-level action to avoid
83
+ logging internal calls.
84
+ """
85
+
86
+ @wraps(func)
87
+ async def wrapper(self, *args, **kwargs):
88
+ # Skip logging if we're already inside a high-level action
89
+ if _in_high_level_action.get():
90
+ return await func(self, *args, **kwargs)
91
+
92
+ action_name = func.__name__
93
+ start_time = time.time()
94
+
95
+ inputs = {
96
+ "args": args,
97
+ "kwargs": kwargs,
98
+ }
99
+
100
+ try:
101
+ result = await func(self, *args, **kwargs)
102
+ execution_time = time.time() - start_time
103
+
104
+ page_load_time = None
105
+ if isinstance(result, dict) and 'page_load_time_ms' in result:
106
+ page_load_time = result['page_load_time_ms'] / 1000.0
107
+
108
+ await self._log_action(
109
+ action_name=action_name,
110
+ inputs=inputs,
111
+ outputs=result,
112
+ execution_time=execution_time,
113
+ page_load_time=page_load_time,
114
+ )
115
+
116
+ return result
117
+
118
+ except Exception as e:
119
+ execution_time = time.time() - start_time
120
+ error_msg = f"{type(e).__name__}: {e!s}"
121
+
122
+ await self._log_action(
123
+ action_name=action_name,
124
+ inputs=inputs,
125
+ outputs=None,
126
+ execution_time=execution_time,
127
+ error=error_msg,
128
+ )
129
+
130
+ raise
131
+
132
+ return wrapper
133
+
134
+
135
+ def high_level_action(func):
136
+ """Decorator for high-level actions that should suppress low-level logging.
137
+
138
+ When a function is decorated with this, all low-level action_logger
139
+ decorated functions called within it will skip logging. This decorator
140
+ itself will log the high-level action.
141
+ """
142
+
143
+ @wraps(func)
144
+ async def wrapper(self, *args, **kwargs):
145
+ action_name = func.__name__
146
+ start_time = time.time()
147
+
148
+ inputs = {
149
+ "args": args,
150
+ "kwargs": kwargs,
151
+ }
152
+
153
+ # Set the context variable to indicate we're in a high-level action
154
+ token = _in_high_level_action.set(True)
155
+ try:
156
+ result = await func(self, *args, **kwargs)
157
+ execution_time = time.time() - start_time
158
+
159
+ # Log the high-level action
160
+ if hasattr(self, '_get_ws_wrapper'):
161
+ # This is a HybridBrowserToolkit instance
162
+ ws_wrapper = await self._get_ws_wrapper()
163
+ await ws_wrapper._log_action(
164
+ action_name=action_name,
165
+ inputs=inputs,
166
+ outputs=result,
167
+ execution_time=execution_time,
168
+ page_load_time=None,
169
+ )
170
+
171
+ return result
172
+
173
+ except Exception as e:
174
+ execution_time = time.time() - start_time
175
+ error_msg = f"{type(e).__name__}: {e!s}"
176
+
177
+ # Log the error
178
+ if hasattr(self, '_get_ws_wrapper'):
179
+ ws_wrapper = await self._get_ws_wrapper()
180
+ await ws_wrapper._log_action(
181
+ action_name=action_name,
182
+ inputs=inputs,
183
+ outputs=None,
184
+ execution_time=execution_time,
185
+ error=error_msg,
186
+ )
187
+
188
+ raise
189
+ finally:
190
+ # Reset the context variable
191
+ _in_high_level_action.reset(token)
192
+
193
+ return wrapper
194
+
195
+
196
+ class WebSocketBrowserWrapper:
197
+ """Python wrapper for the TypeScript hybrid browser
198
+ toolkit implementation using WebSocket."""
199
+
200
+ def __init__(self, config: Optional[Dict[str, Any]] = None):
201
+ """Initialize the wrapper.
202
+
203
+ Args:
204
+ config: Configuration dictionary for the browser toolkit
205
+ """
206
+ if websockets is None:
207
+ raise ImportError(
208
+ "websockets package is required for WebSocket communication. "
209
+ "Install with: pip install websockets"
210
+ )
211
+
212
+ self.config = config or {}
213
+ self.ts_dir = os.path.join(os.path.dirname(__file__), 'ts')
214
+ self.process: Optional[subprocess.Popen] = None
215
+ self.websocket = None
216
+ self.server_port = None
217
+ self._send_lock = asyncio.Lock()
218
+ self._receive_task = None
219
+ self._pending_responses: Dict[str, asyncio.Future[Dict[str, Any]]] = {}
220
+ self._browser_opened = False
221
+ self._server_ready_future = None
222
+
223
+ self.browser_log_to_file = (config or {}).get(
224
+ 'browser_log_to_file', False
225
+ )
226
+ self.log_dir = (config or {}).get('log_dir', 'browser_log')
227
+ self.session_id = (config or {}).get('session_id', 'default')
228
+ self.log_file_path: Optional[str] = None
229
+ self.log_buffer: List[Dict[str, Any]] = []
230
+ self.ts_log_file_path: Optional[str] = None
231
+ self.ts_log_file = None
232
+ self._log_reader_task = None
233
+
234
+ if self.browser_log_to_file:
235
+ log_dir = self.log_dir if self.log_dir else "browser_log"
236
+ os.makedirs(log_dir, exist_ok=True)
237
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
238
+ self.log_file_path = os.path.join(
239
+ log_dir,
240
+ f"hybrid_browser_toolkit_ws_{timestamp}_{self.session_id}.log",
241
+ )
242
+ self.ts_log_file_path = os.path.join(
243
+ log_dir,
244
+ f"typescript_console_{timestamp}_{self.session_id}.log",
245
+ )
246
+
247
+ async def __aenter__(self):
248
+ """Async context manager entry."""
249
+ await self.start()
250
+ return self
251
+
252
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
253
+ """Async context manager exit."""
254
+ await self.stop()
255
+
256
+ async def _cleanup_existing_processes(self):
257
+ """Clean up any existing Node.js WebSocket server processes."""
258
+ import psutil
259
+
260
+ cleaned_count = 0
261
+ for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
262
+ try:
263
+ if (
264
+ proc.info['name']
265
+ and 'node' in proc.info['name'].lower()
266
+ and proc.info['cmdline']
267
+ and any(
268
+ 'websocket-server.js' in arg
269
+ for arg in proc.info['cmdline']
270
+ )
271
+ ):
272
+ if any(self.ts_dir in arg for arg in proc.info['cmdline']):
273
+ logger.warning(
274
+ f"Found existing WebSocket server process "
275
+ f"(PID: {proc.info['pid']}). "
276
+ f"Terminating it to prevent conflicts."
277
+ )
278
+ proc.terminate()
279
+ try:
280
+ proc.wait(timeout=3)
281
+ except psutil.TimeoutExpired:
282
+ proc.kill()
283
+ cleaned_count += 1
284
+ except (
285
+ psutil.NoSuchProcess,
286
+ psutil.AccessDenied,
287
+ psutil.ZombieProcess,
288
+ ):
289
+ pass
290
+
291
+ if cleaned_count > 0:
292
+ logger.warning(
293
+ f"Cleaned up {cleaned_count} existing WebSocket server "
294
+ f"process(es). This may have been caused by improper "
295
+ f"shutdown in previous sessions."
296
+ )
297
+ await asyncio.sleep(0.5)
298
+
299
+ async def start(self):
300
+ """Start the WebSocket server and connect to it."""
301
+ await self._cleanup_existing_processes()
302
+
303
+ npm_cmd, node_cmd = await check_and_install_dependencies(self.ts_dir)
304
+
305
+ import platform
306
+
307
+ use_shell = platform.system() == 'Windows'
308
+
309
+ self.process = subprocess.Popen(
310
+ [node_cmd, 'websocket-server.js'],
311
+ cwd=self.ts_dir,
312
+ stdout=subprocess.PIPE,
313
+ stderr=subprocess.STDOUT,
314
+ text=True,
315
+ encoding='utf-8',
316
+ bufsize=1,
317
+ shell=use_shell,
318
+ )
319
+
320
+ self._server_ready_future = asyncio.get_running_loop().create_future()
321
+
322
+ self._log_reader_task = asyncio.create_task(
323
+ self._read_and_log_output()
324
+ )
325
+
326
+ if self.browser_log_to_file and self.ts_log_file_path:
327
+ logger.info(
328
+ f"TypeScript console logs will be written to: "
329
+ f"{self.ts_log_file_path}"
330
+ )
331
+
332
+ server_ready = False
333
+ timeout = 10
334
+
335
+ try:
336
+ await asyncio.wait_for(self._server_ready_future, timeout=timeout)
337
+ server_ready = True
338
+ except asyncio.TimeoutError:
339
+ server_ready = False
340
+
341
+ if not server_ready:
342
+ await _cleanup_process_and_tasks(
343
+ self.process,
344
+ self._log_reader_task,
345
+ getattr(self, 'ts_log_file', None),
346
+ )
347
+ self.ts_log_file = None
348
+ self.process = None
349
+
350
+ error_msg = _create_memory_aware_error(
351
+ "WebSocket server failed to start within timeout"
352
+ )
353
+ raise RuntimeError(error_msg)
354
+
355
+ max_retries = 3
356
+ retry_delays = [1, 2, 4]
357
+
358
+ for attempt in range(max_retries):
359
+ try:
360
+ connect_timeout = 10.0 + (attempt * 5.0)
361
+
362
+ logger.info(
363
+ f"Attempting to connect to WebSocket server "
364
+ f"(attempt {attempt + 1}/{max_retries}, "
365
+ f"timeout: {connect_timeout}s)"
366
+ )
367
+
368
+ self.websocket = await asyncio.wait_for(
369
+ websockets.connect(
370
+ f"ws://localhost:{self.server_port}",
371
+ ping_interval=30,
372
+ ping_timeout=10,
373
+ max_size=50 * 1024 * 1024,
374
+ ),
375
+ timeout=connect_timeout,
376
+ )
377
+ logger.info("Connected to WebSocket server")
378
+ break
379
+
380
+ except asyncio.TimeoutError:
381
+ if attempt < max_retries - 1:
382
+ delay = retry_delays[attempt]
383
+ logger.warning(
384
+ f"WebSocket handshake timeout "
385
+ f"(attempt {attempt + 1}/{max_retries}). "
386
+ f"Retrying in {delay} seconds..."
387
+ )
388
+ await asyncio.sleep(delay)
389
+ else:
390
+ raise RuntimeError(
391
+ f"Failed to connect to WebSocket server after "
392
+ f"{max_retries} attempts: Handshake timeout"
393
+ )
394
+
395
+ except Exception as e:
396
+ if attempt < max_retries - 1 and "timed out" in str(e).lower():
397
+ delay = retry_delays[attempt]
398
+ logger.warning(
399
+ f"WebSocket connection failed "
400
+ f"(attempt {attempt + 1}/{max_retries}): {e}. "
401
+ f"Retrying in {delay} seconds..."
402
+ )
403
+ await asyncio.sleep(delay)
404
+ else:
405
+ break
406
+
407
+ if not self.websocket:
408
+ await _cleanup_process_and_tasks(
409
+ self.process,
410
+ self._log_reader_task,
411
+ getattr(self, 'ts_log_file', None),
412
+ )
413
+ self.ts_log_file = None
414
+ self.process = None
415
+
416
+ error_msg = _create_memory_aware_error(
417
+ "Failed to connect to WebSocket server after multiple attempts"
418
+ )
419
+ raise RuntimeError(error_msg)
420
+
421
+ self._receive_task = asyncio.create_task(self._receive_loop())
422
+
423
+ await self._send_command('init', self.config)
424
+
425
+ if self.config.get('cdpUrl'):
426
+ self._browser_opened = True
427
+
428
+ async def stop(self):
429
+ """Stop the WebSocket connection and server."""
430
+ if self.websocket:
431
+ with contextlib.suppress(asyncio.TimeoutError, Exception):
432
+ await asyncio.wait_for(
433
+ self._send_command('shutdown', {}),
434
+ timeout=2.0,
435
+ )
436
+
437
+ with contextlib.suppress(Exception):
438
+ await self.websocket.close()
439
+ self.websocket = None
440
+
441
+ self._browser_opened = False
442
+
443
+ # Gracefully stop the Node process before cancelling the log reader
444
+ if self.process:
445
+ try:
446
+ # give the process a short grace period to exit after shutdown
447
+ self.process.wait(timeout=2)
448
+ except subprocess.TimeoutExpired:
449
+ try:
450
+ self.process.terminate()
451
+ self.process.wait(timeout=3)
452
+ except subprocess.TimeoutExpired:
453
+ with contextlib.suppress(ProcessLookupError, Exception):
454
+ self.process.kill()
455
+ self.process.wait()
456
+ except Exception as e:
457
+ logger.warning(f"Error terminating process: {e}")
458
+ except Exception as e:
459
+ logger.warning(f"Error waiting for process: {e}")
460
+
461
+ # Now cancel background tasks (reader won't block on readline)
462
+ tasks_to_cancel = [
463
+ ('_receive_task', self._receive_task),
464
+ ('_log_reader_task', self._log_reader_task),
465
+ ]
466
+ for _, task in tasks_to_cancel:
467
+ if task and not task.done():
468
+ task.cancel()
469
+ with contextlib.suppress(asyncio.CancelledError):
470
+ await task
471
+
472
+ # Close TS log file if open
473
+ if getattr(self, 'ts_log_file', None):
474
+ with contextlib.suppress(Exception):
475
+ self.ts_log_file.close()
476
+ self.ts_log_file = None
477
+
478
+ # Ensure process handle cleared
479
+ self.process = None
480
+
481
+ async def disconnect_only(self):
482
+ """Disconnect WebSocket and stop server without closing the browser.
483
+
484
+ This is useful for CDP mode where the browser should remain open.
485
+ """
486
+ if self.websocket:
487
+ with contextlib.suppress(Exception):
488
+ await self.websocket.close()
489
+ self.websocket = None
490
+
491
+ self._browser_opened = False
492
+
493
+ # Stop the Node process
494
+ if self.process:
495
+ try:
496
+ # Send SIGTERM to gracefully shutdown
497
+ self.process.terminate()
498
+ self.process.wait(timeout=3)
499
+ except subprocess.TimeoutExpired:
500
+ # Force kill if needed
501
+ with contextlib.suppress(ProcessLookupError, Exception):
502
+ self.process.kill()
503
+ self.process.wait()
504
+ except Exception as e:
505
+ logger.warning(f"Error terminating process: {e}")
506
+
507
+ # Cancel background tasks
508
+ tasks_to_cancel = [
509
+ ('_receive_task', self._receive_task),
510
+ ('_log_reader_task', self._log_reader_task),
511
+ ]
512
+ for _, task in tasks_to_cancel:
513
+ if task and not task.done():
514
+ task.cancel()
515
+ with contextlib.suppress(asyncio.CancelledError):
516
+ await task
517
+
518
+ # Close TS log file if open
519
+ if getattr(self, 'ts_log_file', None):
520
+ with contextlib.suppress(Exception):
521
+ self.ts_log_file.close()
522
+ self.ts_log_file = None
523
+
524
+ # Ensure process handle cleared
525
+ self.process = None
526
+
527
+ logger.info("WebSocket disconnected without closing browser")
528
+
529
+ async def _log_action(
530
+ self,
531
+ action_name: str,
532
+ inputs: Dict[str, Any],
533
+ outputs: Any,
534
+ execution_time: float,
535
+ page_load_time: Optional[float] = None,
536
+ error: Optional[str] = None,
537
+ ) -> None:
538
+ """Log action details with comprehensive
539
+ information including detailed timing breakdown."""
540
+ if not self.browser_log_to_file or not self.log_file_path:
541
+ return
542
+
543
+ # Create log entry
544
+ log_entry = {
545
+ "timestamp": datetime.datetime.now().isoformat(),
546
+ "session_id": self.session_id,
547
+ "action": action_name,
548
+ "execution_time_ms": round(execution_time * 1000, 2),
549
+ "inputs": inputs,
550
+ }
551
+
552
+ if error:
553
+ log_entry["error"] = error
554
+ else:
555
+ # Handle ToolResult objects for JSON serialization
556
+ if hasattr(outputs, 'text') and hasattr(outputs, 'images'):
557
+ # This is a ToolResult object
558
+ log_entry["outputs"] = {
559
+ "text": outputs.text,
560
+ "images_count": len(outputs.images)
561
+ if outputs.images
562
+ else 0,
563
+ }
564
+ else:
565
+ log_entry["outputs"] = outputs
566
+
567
+ if page_load_time is not None:
568
+ log_entry["page_load_time_ms"] = round(page_load_time * 1000, 2)
569
+
570
+ # Write to log file
571
+ try:
572
+ with open(self.log_file_path, 'a', encoding='utf-8') as f:
573
+ f.write(
574
+ json.dumps(log_entry, ensure_ascii=False, indent=2) + '\n'
575
+ )
576
+ except Exception as e:
577
+ logger.error(f"Failed to write to log file: {e}")
578
+
579
+ async def _receive_loop(self):
580
+ r"""Background task to receive messages from WebSocket."""
581
+ try:
582
+ while self.websocket:
583
+ try:
584
+ response_data = await self.websocket.recv()
585
+ response = json.loads(response_data)
586
+
587
+ message_id = response.get('id')
588
+ if message_id and message_id in self._pending_responses:
589
+ # Set the result for the waiting coroutine
590
+ future = self._pending_responses.pop(message_id)
591
+ if not future.done():
592
+ future.set_result(response)
593
+ else:
594
+ # Log unexpected messages
595
+ logger.warning(
596
+ f"Received unexpected message: {response}"
597
+ )
598
+
599
+ except asyncio.CancelledError:
600
+ break
601
+ except Exception as e:
602
+ # Check if it's a normal WebSocket close
603
+ if isinstance(e, websockets.exceptions.ConnectionClosed):
604
+ if e.code == 1000: # Normal closure
605
+ logger.debug(f"WebSocket closed normally: {e}")
606
+ else:
607
+ logger.warning(
608
+ f"WebSocket closed with code {e.code}: {e}"
609
+ )
610
+ else:
611
+ logger.error(f"Error in receive loop: {e}")
612
+ # Notify all pending futures of the error
613
+ for future in self._pending_responses.values():
614
+ if not future.done():
615
+ future.set_exception(e)
616
+ self._pending_responses.clear()
617
+ break
618
+ finally:
619
+ logger.debug("Receive loop terminated")
620
+
621
+ async def _ensure_connection(self) -> None:
622
+ """Ensure WebSocket connection is alive."""
623
+ if not self.websocket:
624
+ error_msg = _create_memory_aware_error("WebSocket not connected")
625
+ raise RuntimeError(error_msg)
626
+
627
+ # Check if connection is still alive
628
+ try:
629
+ # Send a ping and wait for the corresponding pong (bounded wait)
630
+ pong_waiter = await self.websocket.ping()
631
+ await asyncio.wait_for(pong_waiter, timeout=5.0)
632
+ except Exception as e:
633
+ logger.warning(f"WebSocket ping failed: {e}")
634
+ self.websocket = None
635
+
636
+ error_msg = _create_memory_aware_error("WebSocket connection lost")
637
+ raise RuntimeError(error_msg)
638
+
639
+ async def _send_command(
640
+ self, command: str, params: Dict[str, Any]
641
+ ) -> Dict[str, Any]:
642
+ """Send a command to the WebSocket server and get response."""
643
+ await self._ensure_connection()
644
+
645
+ # Process params to ensure refs have 'e' prefix
646
+ params = self._process_refs_in_params(params)
647
+
648
+ message_id = str(uuid.uuid4())
649
+ message = {'id': message_id, 'command': command, 'params': params}
650
+
651
+ # Create a future for this message
652
+ loop = asyncio.get_running_loop()
653
+ future: asyncio.Future[Dict[str, Any]] = loop.create_future()
654
+ self._pending_responses[message_id] = future
655
+
656
+ try:
657
+ # Use lock only for sending to prevent interleaved messages
658
+ async with self._send_lock:
659
+ if self.websocket is None:
660
+ raise RuntimeError("WebSocket connection not established")
661
+ await self.websocket.send(json.dumps(message))
662
+
663
+ # Wait for response (no lock needed, handled by background
664
+ # receiver)
665
+ try:
666
+ response = await asyncio.wait_for(future, timeout=60.0)
667
+
668
+ if not response.get('success'):
669
+ raise RuntimeError(
670
+ f"Command failed: {response.get('error')}"
671
+ )
672
+ return response['result']
673
+
674
+ except asyncio.TimeoutError:
675
+ # Remove from pending if timeout
676
+ self._pending_responses.pop(message_id, None)
677
+ # Special handling for shutdown command
678
+ if command == 'shutdown':
679
+ logger.debug(
680
+ "Shutdown command timeout is expected - "
681
+ "server may have closed before responding"
682
+ )
683
+ # Return a success response for shutdown
684
+ return {
685
+ 'message': 'Browser shutdown (no response received)'
686
+ }
687
+ raise RuntimeError(
688
+ f"Timeout waiting for response to command: {command}"
689
+ )
690
+
691
+ except Exception as e:
692
+ # Clean up the pending response
693
+ self._pending_responses.pop(message_id, None)
694
+
695
+ # Check if it's a connection closed error
696
+ if (
697
+ "close frame" in str(e)
698
+ or "connection closed" in str(e).lower()
699
+ ):
700
+ # Special handling for shutdown command
701
+ if command == 'shutdown':
702
+ logger.debug(
703
+ f"Connection closed during shutdown (expected): {e}"
704
+ )
705
+ return {'message': 'Browser shutdown (connection closed)'}
706
+ logger.error(f"WebSocket connection closed unexpectedly: {e}")
707
+ # Mark connection as closed
708
+ self.websocket = None
709
+ raise RuntimeError(
710
+ f"WebSocket connection lost "
711
+ f"during {command} operation: {e}"
712
+ )
713
+ else:
714
+ logger.error(f"WebSocket communication error: {e}")
715
+ raise
716
+
717
+ # Browser action methods
718
+ @action_logger
719
+ async def open_browser(
720
+ self, start_url: Optional[str] = None
721
+ ) -> Dict[str, Any]:
722
+ """Open browser."""
723
+ response = await self._send_command(
724
+ 'open_browser', {'startUrl': start_url}
725
+ )
726
+ self._browser_opened = True
727
+ return response
728
+
729
+ @action_logger
730
+ async def close_browser(self) -> str:
731
+ """Close browser."""
732
+ response = await self._send_command('close_browser', {})
733
+ self._browser_opened = False
734
+ return response['message']
735
+
736
+ @action_logger
737
+ async def visit_page(self, url: str) -> Dict[str, Any]:
738
+ """Visit a page.
739
+
740
+ In non-CDP mode, automatically opens browser if not already open.
741
+ """
742
+ if not self._browser_opened:
743
+ is_cdp_mode = bool(self.config.get('cdpUrl'))
744
+
745
+ if not is_cdp_mode:
746
+ logger.info(
747
+ "Browser not open, automatically opening browser..."
748
+ )
749
+ await self.open_browser()
750
+
751
+ response = await self._send_command('visit_page', {'url': url})
752
+ return response
753
+
754
+ @action_logger
755
+ async def get_page_snapshot(self, viewport_limit: bool = False) -> str:
756
+ """Get page snapshot."""
757
+ response = await self._send_command(
758
+ 'get_page_snapshot', {'viewport_limit': viewport_limit}
759
+ )
760
+ # The backend returns the snapshot string directly,
761
+ # not wrapped in an object
762
+ if isinstance(response, str):
763
+ return response
764
+ # Fallback if wrapped in an object
765
+ return response.get('snapshot', '')
766
+
767
+ @action_logger
768
+ async def get_snapshot_for_ai(self) -> Dict[str, Any]:
769
+ """Get snapshot for AI with element details."""
770
+ response = await self._send_command('get_snapshot_for_ai', {})
771
+ return response
772
+
773
+ @action_logger
774
+ async def get_som_screenshot(self) -> ToolResult:
775
+ """Get screenshot."""
776
+ logger.info("Requesting screenshot via WebSocket...")
777
+ start_time = time.time()
778
+
779
+ response = await self._send_command('get_som_screenshot', {})
780
+
781
+ end_time = time.time()
782
+ logger.info(f"Screenshot completed in {end_time - start_time:.2f}s")
783
+
784
+ return ToolResult(text=response['text'], images=response['images'])
785
+
786
+ def _ensure_ref_prefix(self, ref: str) -> str:
787
+ """Ensure ref has proper prefix"""
788
+ if not ref:
789
+ return ref
790
+
791
+ # If ref is purely numeric, add 'e' prefix for main frame
792
+ if ref.isdigit():
793
+ return f'e{ref}'
794
+
795
+ return ref
796
+
797
+ def _process_refs_in_params(
798
+ self, params: Dict[str, Any]
799
+ ) -> Dict[str, Any]:
800
+ """Process parameters to ensure all refs have 'e' prefix."""
801
+ if not params:
802
+ return params
803
+
804
+ # Create a copy to avoid modifying the original
805
+ processed = params.copy()
806
+
807
+ # Handle direct ref parameters
808
+ if 'ref' in processed:
809
+ processed['ref'] = self._ensure_ref_prefix(processed['ref'])
810
+
811
+ # Handle from_ref and to_ref for drag operations
812
+ if 'from_ref' in processed:
813
+ processed['from_ref'] = self._ensure_ref_prefix(
814
+ processed['from_ref']
815
+ )
816
+ if 'to_ref' in processed:
817
+ processed['to_ref'] = self._ensure_ref_prefix(processed['to_ref'])
818
+
819
+ # Handle inputs array for type_multiple
820
+ if 'inputs' in processed and isinstance(processed['inputs'], list):
821
+ processed_inputs = []
822
+ for input_item in processed['inputs']:
823
+ if isinstance(input_item, dict) and 'ref' in input_item:
824
+ processed_input = input_item.copy()
825
+ processed_input['ref'] = self._ensure_ref_prefix(
826
+ input_item['ref']
827
+ )
828
+ processed_inputs.append(processed_input)
829
+ else:
830
+ processed_inputs.append(input_item)
831
+ processed['inputs'] = processed_inputs
832
+
833
+ return processed
834
+
835
+ @action_logger
836
+ async def click(self, ref: str) -> Dict[str, Any]:
837
+ """Click an element."""
838
+ response = await self._send_command('click', {'ref': ref})
839
+ return response
840
+
841
+ @action_logger
842
+ async def type(self, ref: str, text: str) -> Dict[str, Any]:
843
+ """Type text into an element."""
844
+ response = await self._send_command('type', {'ref': ref, 'text': text})
845
+ # Log the response for debugging
846
+ logger.debug(f"Type response for ref {ref}: {response}")
847
+ return response
848
+
849
+ @action_logger
850
+ async def type_multiple(
851
+ self, inputs: List[Dict[str, str]]
852
+ ) -> Dict[str, Any]:
853
+ """Type text into multiple elements."""
854
+ response = await self._send_command('type', {'inputs': inputs})
855
+ return response
856
+
857
+ @action_logger
858
+ async def select(self, ref: str, value: str) -> Dict[str, Any]:
859
+ """Select an option."""
860
+ response = await self._send_command(
861
+ 'select', {'ref': ref, 'value': value}
862
+ )
863
+ return response
864
+
865
+ @action_logger
866
+ async def scroll(self, direction: str, amount: int) -> Dict[str, Any]:
867
+ """Scroll the page."""
868
+ response = await self._send_command(
869
+ 'scroll', {'direction': direction, 'amount': amount}
870
+ )
871
+ return response
872
+
873
+ @action_logger
874
+ async def enter(self) -> Dict[str, Any]:
875
+ """Press enter."""
876
+ response = await self._send_command('enter', {})
877
+ return response
878
+
879
+ @action_logger
880
+ async def mouse_control(
881
+ self, control: str, x: float, y: float
882
+ ) -> Dict[str, Any]:
883
+ """Control the mouse to interact with browser with x, y coordinates."""
884
+ response = await self._send_command(
885
+ 'mouse_control', {'control': control, 'x': x, 'y': y}
886
+ )
887
+ return response
888
+
889
+ @action_logger
890
+ async def mouse_drag(self, from_ref: str, to_ref: str) -> Dict[str, Any]:
891
+ """Control the mouse to drag and drop in the browser using ref IDs."""
892
+ response = await self._send_command(
893
+ 'mouse_drag',
894
+ {'from_ref': from_ref, 'to_ref': to_ref},
895
+ )
896
+ return response
897
+
898
+ @action_logger
899
+ async def press_key(self, keys: List[str]) -> Dict[str, Any]:
900
+ """Press key and key combinations."""
901
+ response = await self._send_command('press_key', {'keys': keys})
902
+ return response
903
+
904
+ @action_logger
905
+ async def back(self) -> Dict[str, Any]:
906
+ """Navigate back."""
907
+ response = await self._send_command('back', {})
908
+ return response
909
+
910
+ @action_logger
911
+ async def forward(self) -> Dict[str, Any]:
912
+ """Navigate forward."""
913
+ response = await self._send_command('forward', {})
914
+ return response
915
+
916
+ @action_logger
917
+ async def switch_tab(self, tab_id: str) -> Dict[str, Any]:
918
+ """Switch to a tab."""
919
+ response = await self._send_command('switch_tab', {'tabId': tab_id})
920
+ return response
921
+
922
+ @action_logger
923
+ async def close_tab(self, tab_id: str) -> Dict[str, Any]:
924
+ """Close a tab."""
925
+ response = await self._send_command('close_tab', {'tabId': tab_id})
926
+ return response
927
+
928
+ @action_logger
929
+ async def get_tab_info(self) -> List[Dict[str, Any]]:
930
+ """Get tab information."""
931
+ response = await self._send_command('get_tab_info', {})
932
+ # The backend returns the tab list directly, not wrapped in an object
933
+ if isinstance(response, list):
934
+ return response
935
+ # Fallback if wrapped in an object
936
+ return response.get('tabs', [])
937
+
938
+ @action_logger
939
+ async def console_view(self) -> List[Dict[str, Any]]:
940
+ """Get current page console view"""
941
+ response = await self._send_command('console_view', {})
942
+
943
+ if isinstance(response, list):
944
+ return response
945
+
946
+ return response.get('logs', [])
947
+
948
+ @action_logger
949
+ async def console_exec(self, code: str) -> Dict[str, Any]:
950
+ """Execute javascript code and get result."""
951
+ response = await self._send_command('console_exec', {'code': code})
952
+ return response
953
+
954
+ @action_logger
955
+ async def wait_user(
956
+ self, timeout_sec: Optional[float] = None
957
+ ) -> Dict[str, Any]:
958
+ """Wait for user input."""
959
+ response = await self._send_command(
960
+ 'wait_user', {'timeout': timeout_sec}
961
+ )
962
+ return response
963
+
964
+ async def _read_and_log_output(self):
965
+ """Read stdout from Node.js process & handle SERVER_READY + logging."""
966
+ if not self.process:
967
+ return
968
+
969
+ try:
970
+ with contextlib.ExitStack() as stack:
971
+ if self.ts_log_file_path:
972
+ self.ts_log_file = stack.enter_context(
973
+ open(self.ts_log_file_path, 'w', encoding='utf-8')
974
+ )
975
+ self.ts_log_file.write(
976
+ f"TypeScript Console Log - Started at "
977
+ f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n"
978
+ )
979
+ self.ts_log_file.write("=" * 80 + "\n")
980
+ self.ts_log_file.flush()
981
+
982
+ while self.process and self.process.poll() is None:
983
+ try:
984
+ line = (
985
+ await asyncio.get_running_loop().run_in_executor(
986
+ None, self.process.stdout.readline
987
+ )
988
+ )
989
+ if not line: # EOF
990
+ break
991
+
992
+ # Check for SERVER_READY message
993
+ if line.startswith('SERVER_READY:'):
994
+ try:
995
+ self.server_port = int(
996
+ line.split(':', 1)[1].strip()
997
+ )
998
+ logger.info(
999
+ f"WebSocket server ready on port "
1000
+ f"{self.server_port}"
1001
+ )
1002
+ if (
1003
+ self._server_ready_future
1004
+ and not self._server_ready_future.done()
1005
+ ):
1006
+ self._server_ready_future.set_result(True)
1007
+ except (ValueError, IndexError) as e:
1008
+ logger.error(
1009
+ f"Failed to parse SERVER_READY: {e}"
1010
+ )
1011
+
1012
+ # Write all output to log file
1013
+ if self.ts_log_file:
1014
+ timestamp = time.strftime('%H:%M:%S')
1015
+ self.ts_log_file.write(f"[{timestamp}] {line}")
1016
+ self.ts_log_file.flush()
1017
+
1018
+ except Exception as e:
1019
+ logger.warning(f"Error reading stdout: {e}")
1020
+ break
1021
+
1022
+ # Footer if we had a file
1023
+ if self.ts_log_file:
1024
+ self.ts_log_file.write("\n" + "=" * 80 + "\n")
1025
+ self.ts_log_file.write(
1026
+ f"TypeScript Console Log - Ended at "
1027
+ f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n"
1028
+ )
1029
+ # ExitStack closes file; clear handle
1030
+ self.ts_log_file = None
1031
+ except Exception as e:
1032
+ logger.warning(f"Error in _read_and_log_output: {e}")