codepp 0.0.437__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 (288) hide show
  1. code_puppy/__init__.py +10 -0
  2. code_puppy/__main__.py +10 -0
  3. code_puppy/agents/__init__.py +31 -0
  4. code_puppy/agents/agent_c_reviewer.py +155 -0
  5. code_puppy/agents/agent_code_puppy.py +117 -0
  6. code_puppy/agents/agent_code_reviewer.py +90 -0
  7. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  8. code_puppy/agents/agent_creator_agent.py +638 -0
  9. code_puppy/agents/agent_golang_reviewer.py +151 -0
  10. code_puppy/agents/agent_helios.py +124 -0
  11. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  12. code_puppy/agents/agent_manager.py +742 -0
  13. code_puppy/agents/agent_pack_leader.py +385 -0
  14. code_puppy/agents/agent_planning.py +165 -0
  15. code_puppy/agents/agent_python_programmer.py +169 -0
  16. code_puppy/agents/agent_python_reviewer.py +90 -0
  17. code_puppy/agents/agent_qa_expert.py +163 -0
  18. code_puppy/agents/agent_qa_kitten.py +208 -0
  19. code_puppy/agents/agent_scheduler.py +121 -0
  20. code_puppy/agents/agent_security_auditor.py +181 -0
  21. code_puppy/agents/agent_terminal_qa.py +323 -0
  22. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  23. code_puppy/agents/base_agent.py +2156 -0
  24. code_puppy/agents/event_stream_handler.py +348 -0
  25. code_puppy/agents/json_agent.py +202 -0
  26. code_puppy/agents/pack/__init__.py +34 -0
  27. code_puppy/agents/pack/bloodhound.py +304 -0
  28. code_puppy/agents/pack/husky.py +327 -0
  29. code_puppy/agents/pack/retriever.py +393 -0
  30. code_puppy/agents/pack/shepherd.py +348 -0
  31. code_puppy/agents/pack/terrier.py +287 -0
  32. code_puppy/agents/pack/watchdog.py +367 -0
  33. code_puppy/agents/prompt_reviewer.py +145 -0
  34. code_puppy/agents/subagent_stream_handler.py +276 -0
  35. code_puppy/api/__init__.py +13 -0
  36. code_puppy/api/app.py +169 -0
  37. code_puppy/api/main.py +21 -0
  38. code_puppy/api/pty_manager.py +453 -0
  39. code_puppy/api/routers/__init__.py +12 -0
  40. code_puppy/api/routers/agents.py +36 -0
  41. code_puppy/api/routers/commands.py +217 -0
  42. code_puppy/api/routers/config.py +75 -0
  43. code_puppy/api/routers/sessions.py +234 -0
  44. code_puppy/api/templates/terminal.html +361 -0
  45. code_puppy/api/websocket.py +154 -0
  46. code_puppy/callbacks.py +692 -0
  47. code_puppy/chatgpt_codex_client.py +338 -0
  48. code_puppy/claude_cache_client.py +672 -0
  49. code_puppy/cli_runner.py +1073 -0
  50. code_puppy/command_line/__init__.py +1 -0
  51. code_puppy/command_line/add_model_menu.py +1092 -0
  52. code_puppy/command_line/agent_menu.py +662 -0
  53. code_puppy/command_line/attachments.py +395 -0
  54. code_puppy/command_line/autosave_menu.py +704 -0
  55. code_puppy/command_line/clipboard.py +527 -0
  56. code_puppy/command_line/colors_menu.py +532 -0
  57. code_puppy/command_line/command_handler.py +293 -0
  58. code_puppy/command_line/command_registry.py +150 -0
  59. code_puppy/command_line/config_commands.py +719 -0
  60. code_puppy/command_line/core_commands.py +867 -0
  61. code_puppy/command_line/diff_menu.py +865 -0
  62. code_puppy/command_line/file_path_completion.py +73 -0
  63. code_puppy/command_line/load_context_completion.py +52 -0
  64. code_puppy/command_line/mcp/__init__.py +10 -0
  65. code_puppy/command_line/mcp/base.py +32 -0
  66. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  67. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  68. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  69. code_puppy/command_line/mcp/edit_command.py +148 -0
  70. code_puppy/command_line/mcp/handler.py +138 -0
  71. code_puppy/command_line/mcp/help_command.py +147 -0
  72. code_puppy/command_line/mcp/install_command.py +214 -0
  73. code_puppy/command_line/mcp/install_menu.py +705 -0
  74. code_puppy/command_line/mcp/list_command.py +94 -0
  75. code_puppy/command_line/mcp/logs_command.py +235 -0
  76. code_puppy/command_line/mcp/remove_command.py +82 -0
  77. code_puppy/command_line/mcp/restart_command.py +100 -0
  78. code_puppy/command_line/mcp/search_command.py +123 -0
  79. code_puppy/command_line/mcp/start_all_command.py +135 -0
  80. code_puppy/command_line/mcp/start_command.py +117 -0
  81. code_puppy/command_line/mcp/status_command.py +184 -0
  82. code_puppy/command_line/mcp/stop_all_command.py +112 -0
  83. code_puppy/command_line/mcp/stop_command.py +80 -0
  84. code_puppy/command_line/mcp/test_command.py +107 -0
  85. code_puppy/command_line/mcp/utils.py +129 -0
  86. code_puppy/command_line/mcp/wizard_utils.py +334 -0
  87. code_puppy/command_line/mcp_completion.py +174 -0
  88. code_puppy/command_line/model_picker_completion.py +197 -0
  89. code_puppy/command_line/model_settings_menu.py +932 -0
  90. code_puppy/command_line/motd.py +96 -0
  91. code_puppy/command_line/onboarding_slides.py +179 -0
  92. code_puppy/command_line/onboarding_wizard.py +342 -0
  93. code_puppy/command_line/pin_command_completion.py +329 -0
  94. code_puppy/command_line/prompt_toolkit_completion.py +846 -0
  95. code_puppy/command_line/session_commands.py +302 -0
  96. code_puppy/command_line/shell_passthrough.py +145 -0
  97. code_puppy/command_line/skills_completion.py +160 -0
  98. code_puppy/command_line/uc_menu.py +893 -0
  99. code_puppy/command_line/utils.py +93 -0
  100. code_puppy/command_line/wiggum_state.py +78 -0
  101. code_puppy/config.py +1770 -0
  102. code_puppy/error_logging.py +134 -0
  103. code_puppy/gemini_code_assist.py +385 -0
  104. code_puppy/gemini_model.py +754 -0
  105. code_puppy/hook_engine/README.md +105 -0
  106. code_puppy/hook_engine/__init__.py +21 -0
  107. code_puppy/hook_engine/aliases.py +155 -0
  108. code_puppy/hook_engine/engine.py +221 -0
  109. code_puppy/hook_engine/executor.py +296 -0
  110. code_puppy/hook_engine/matcher.py +156 -0
  111. code_puppy/hook_engine/models.py +240 -0
  112. code_puppy/hook_engine/registry.py +106 -0
  113. code_puppy/hook_engine/validator.py +144 -0
  114. code_puppy/http_utils.py +361 -0
  115. code_puppy/keymap.py +128 -0
  116. code_puppy/main.py +10 -0
  117. code_puppy/mcp_/__init__.py +66 -0
  118. code_puppy/mcp_/async_lifecycle.py +286 -0
  119. code_puppy/mcp_/blocking_startup.py +469 -0
  120. code_puppy/mcp_/captured_stdio_server.py +275 -0
  121. code_puppy/mcp_/circuit_breaker.py +290 -0
  122. code_puppy/mcp_/config_wizard.py +507 -0
  123. code_puppy/mcp_/dashboard.py +308 -0
  124. code_puppy/mcp_/error_isolation.py +407 -0
  125. code_puppy/mcp_/examples/retry_example.py +226 -0
  126. code_puppy/mcp_/health_monitor.py +589 -0
  127. code_puppy/mcp_/managed_server.py +428 -0
  128. code_puppy/mcp_/manager.py +807 -0
  129. code_puppy/mcp_/mcp_logs.py +224 -0
  130. code_puppy/mcp_/registry.py +451 -0
  131. code_puppy/mcp_/retry_manager.py +337 -0
  132. code_puppy/mcp_/server_registry_catalog.py +1126 -0
  133. code_puppy/mcp_/status_tracker.py +355 -0
  134. code_puppy/mcp_/system_tools.py +209 -0
  135. code_puppy/mcp_prompts/__init__.py +1 -0
  136. code_puppy/mcp_prompts/hook_creator.py +103 -0
  137. code_puppy/messaging/__init__.py +255 -0
  138. code_puppy/messaging/bus.py +613 -0
  139. code_puppy/messaging/commands.py +167 -0
  140. code_puppy/messaging/markdown_patches.py +57 -0
  141. code_puppy/messaging/message_queue.py +361 -0
  142. code_puppy/messaging/messages.py +569 -0
  143. code_puppy/messaging/queue_console.py +271 -0
  144. code_puppy/messaging/renderers.py +311 -0
  145. code_puppy/messaging/rich_renderer.py +1158 -0
  146. code_puppy/messaging/spinner/__init__.py +83 -0
  147. code_puppy/messaging/spinner/console_spinner.py +240 -0
  148. code_puppy/messaging/spinner/spinner_base.py +95 -0
  149. code_puppy/messaging/subagent_console.py +460 -0
  150. code_puppy/model_factory.py +848 -0
  151. code_puppy/model_switching.py +63 -0
  152. code_puppy/model_utils.py +168 -0
  153. code_puppy/models.json +174 -0
  154. code_puppy/models_dev_api.json +1 -0
  155. code_puppy/models_dev_parser.py +592 -0
  156. code_puppy/plugins/__init__.py +186 -0
  157. code_puppy/plugins/agent_skills/__init__.py +22 -0
  158. code_puppy/plugins/agent_skills/config.py +175 -0
  159. code_puppy/plugins/agent_skills/discovery.py +136 -0
  160. code_puppy/plugins/agent_skills/downloader.py +392 -0
  161. code_puppy/plugins/agent_skills/installer.py +22 -0
  162. code_puppy/plugins/agent_skills/metadata.py +219 -0
  163. code_puppy/plugins/agent_skills/prompt_builder.py +60 -0
  164. code_puppy/plugins/agent_skills/register_callbacks.py +241 -0
  165. code_puppy/plugins/agent_skills/remote_catalog.py +322 -0
  166. code_puppy/plugins/agent_skills/skill_catalog.py +257 -0
  167. code_puppy/plugins/agent_skills/skills_install_menu.py +664 -0
  168. code_puppy/plugins/agent_skills/skills_menu.py +781 -0
  169. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  170. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  171. code_puppy/plugins/antigravity_oauth/antigravity_model.py +706 -0
  172. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  173. code_puppy/plugins/antigravity_oauth/constants.py +133 -0
  174. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  175. code_puppy/plugins/antigravity_oauth/register_callbacks.py +518 -0
  176. code_puppy/plugins/antigravity_oauth/storage.py +288 -0
  177. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  178. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  179. code_puppy/plugins/antigravity_oauth/transport.py +863 -0
  180. code_puppy/plugins/antigravity_oauth/utils.py +168 -0
  181. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  182. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  183. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +329 -0
  184. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +176 -0
  185. code_puppy/plugins/chatgpt_oauth/test_plugin.py +301 -0
  186. code_puppy/plugins/chatgpt_oauth/utils.py +523 -0
  187. code_puppy/plugins/claude_code_hooks/__init__.py +1 -0
  188. code_puppy/plugins/claude_code_hooks/config.py +137 -0
  189. code_puppy/plugins/claude_code_hooks/register_callbacks.py +175 -0
  190. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  191. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  192. code_puppy/plugins/claude_code_oauth/__init__.py +25 -0
  193. code_puppy/plugins/claude_code_oauth/config.py +52 -0
  194. code_puppy/plugins/claude_code_oauth/register_callbacks.py +453 -0
  195. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  196. code_puppy/plugins/claude_code_oauth/token_refresh_heartbeat.py +241 -0
  197. code_puppy/plugins/claude_code_oauth/utils.py +640 -0
  198. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  199. code_puppy/plugins/customizable_commands/register_callbacks.py +152 -0
  200. code_puppy/plugins/example_custom_command/README.md +280 -0
  201. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  202. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  203. code_puppy/plugins/file_permission_handler/register_callbacks.py +470 -0
  204. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  205. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  206. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  207. code_puppy/plugins/hook_creator/__init__.py +1 -0
  208. code_puppy/plugins/hook_creator/register_callbacks.py +33 -0
  209. code_puppy/plugins/hook_manager/__init__.py +1 -0
  210. code_puppy/plugins/hook_manager/config.py +290 -0
  211. code_puppy/plugins/hook_manager/hooks_menu.py +564 -0
  212. code_puppy/plugins/hook_manager/register_callbacks.py +227 -0
  213. code_puppy/plugins/oauth_puppy_html.py +228 -0
  214. code_puppy/plugins/scheduler/__init__.py +1 -0
  215. code_puppy/plugins/scheduler/register_callbacks.py +88 -0
  216. code_puppy/plugins/scheduler/scheduler_menu.py +522 -0
  217. code_puppy/plugins/scheduler/scheduler_wizard.py +341 -0
  218. code_puppy/plugins/shell_safety/__init__.py +6 -0
  219. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  220. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  221. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  222. code_puppy/plugins/synthetic_status/__init__.py +1 -0
  223. code_puppy/plugins/synthetic_status/register_callbacks.py +132 -0
  224. code_puppy/plugins/synthetic_status/status_api.py +147 -0
  225. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  226. code_puppy/plugins/universal_constructor/models.py +138 -0
  227. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  228. code_puppy/plugins/universal_constructor/registry.py +302 -0
  229. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  230. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  231. code_puppy/pydantic_patches.py +356 -0
  232. code_puppy/reopenable_async_client.py +232 -0
  233. code_puppy/round_robin_model.py +150 -0
  234. code_puppy/scheduler/__init__.py +41 -0
  235. code_puppy/scheduler/__main__.py +9 -0
  236. code_puppy/scheduler/cli.py +118 -0
  237. code_puppy/scheduler/config.py +126 -0
  238. code_puppy/scheduler/daemon.py +280 -0
  239. code_puppy/scheduler/executor.py +155 -0
  240. code_puppy/scheduler/platform.py +19 -0
  241. code_puppy/scheduler/platform_unix.py +22 -0
  242. code_puppy/scheduler/platform_win.py +32 -0
  243. code_puppy/session_storage.py +338 -0
  244. code_puppy/status_display.py +257 -0
  245. code_puppy/summarization_agent.py +176 -0
  246. code_puppy/terminal_utils.py +418 -0
  247. code_puppy/tools/__init__.py +501 -0
  248. code_puppy/tools/agent_tools.py +603 -0
  249. code_puppy/tools/ask_user_question/__init__.py +26 -0
  250. code_puppy/tools/ask_user_question/constants.py +73 -0
  251. code_puppy/tools/ask_user_question/demo_tui.py +55 -0
  252. code_puppy/tools/ask_user_question/handler.py +232 -0
  253. code_puppy/tools/ask_user_question/models.py +304 -0
  254. code_puppy/tools/ask_user_question/registration.py +26 -0
  255. code_puppy/tools/ask_user_question/renderers.py +309 -0
  256. code_puppy/tools/ask_user_question/terminal_ui.py +329 -0
  257. code_puppy/tools/ask_user_question/theme.py +155 -0
  258. code_puppy/tools/ask_user_question/tui_loop.py +423 -0
  259. code_puppy/tools/browser/__init__.py +37 -0
  260. code_puppy/tools/browser/browser_control.py +289 -0
  261. code_puppy/tools/browser/browser_interactions.py +545 -0
  262. code_puppy/tools/browser/browser_locators.py +640 -0
  263. code_puppy/tools/browser/browser_manager.py +378 -0
  264. code_puppy/tools/browser/browser_navigation.py +251 -0
  265. code_puppy/tools/browser/browser_screenshot.py +179 -0
  266. code_puppy/tools/browser/browser_scripts.py +462 -0
  267. code_puppy/tools/browser/browser_workflows.py +221 -0
  268. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  269. code_puppy/tools/browser/terminal_command_tools.py +534 -0
  270. code_puppy/tools/browser/terminal_screenshot_tools.py +552 -0
  271. code_puppy/tools/browser/terminal_tools.py +525 -0
  272. code_puppy/tools/command_runner.py +1346 -0
  273. code_puppy/tools/common.py +1409 -0
  274. code_puppy/tools/display.py +84 -0
  275. code_puppy/tools/file_modifications.py +886 -0
  276. code_puppy/tools/file_operations.py +802 -0
  277. code_puppy/tools/scheduler_tools.py +412 -0
  278. code_puppy/tools/skills_tools.py +244 -0
  279. code_puppy/tools/subagent_context.py +158 -0
  280. code_puppy/tools/tools_content.py +51 -0
  281. code_puppy/tools/universal_constructor.py +889 -0
  282. code_puppy/uvx_detection.py +242 -0
  283. code_puppy/version_checker.py +82 -0
  284. codepp-0.0.437.dist-info/METADATA +766 -0
  285. codepp-0.0.437.dist-info/RECORD +288 -0
  286. codepp-0.0.437.dist-info/WHEEL +4 -0
  287. codepp-0.0.437.dist-info/entry_points.txt +3 -0
  288. codepp-0.0.437.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,275 @@
1
+ """
2
+ Custom MCPServerStdio that captures stderr output properly.
3
+
4
+ This module provides a version of MCPServerStdio that captures subprocess
5
+ stderr output and makes it available through proper logging channels.
6
+ """
7
+
8
+ import asyncio
9
+ import logging
10
+ import os
11
+ from contextlib import asynccontextmanager
12
+ from typing import AsyncIterator, Optional, Sequence
13
+
14
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
15
+ from mcp.client.stdio import StdioServerParameters, stdio_client
16
+ from mcp.shared.session import SessionMessage
17
+ from pydantic_ai.mcp import MCPServerStdio
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ class StderrCapture:
23
+ """
24
+ Captures stderr output using a pipe and background reader.
25
+ """
26
+
27
+ def __init__(self, name: str, handler: Optional[callable] = None):
28
+ """
29
+ Initialize stderr capture.
30
+
31
+ Args:
32
+ name: Name for this capture stream
33
+ handler: Optional function to call with captured lines
34
+ """
35
+ self.name = name
36
+ self.handler = handler or self._default_handler
37
+ self._captured_lines = []
38
+ self._reader_task = None
39
+ self._pipe_r = None
40
+ self._pipe_w = None
41
+
42
+ def _default_handler(self, line: str):
43
+ """Default handler that logs to Python logging."""
44
+ if line.strip():
45
+ logger.debug(f"[MCP {self.name}] {line.rstrip()}")
46
+
47
+ async def start_capture(self):
48
+ """Start capturing stderr by creating a pipe and reader task."""
49
+ # Create a pipe for capturing stderr
50
+ self._pipe_r, self._pipe_w = os.pipe()
51
+
52
+ # Make the read end non-blocking
53
+ os.set_blocking(self._pipe_r, False)
54
+
55
+ # Start background task to read from pipe
56
+ self._reader_task = asyncio.create_task(self._read_pipe())
57
+
58
+ # Return the write end as the file descriptor for stderr
59
+ return self._pipe_w
60
+
61
+ async def _read_pipe(self):
62
+ """Background task to read from the pipe."""
63
+ loop = asyncio.get_running_loop()
64
+ buffer = b""
65
+
66
+ try:
67
+ while True:
68
+ # Use asyncio's add_reader for efficient async reading
69
+ future = asyncio.Future()
70
+
71
+ def read_callback(future=future):
72
+ try:
73
+ data = os.read(self._pipe_r, 4096)
74
+ future.set_result(data)
75
+ except BlockingIOError:
76
+ future.set_result(b"")
77
+ except Exception as e:
78
+ future.set_exception(e)
79
+
80
+ loop.add_reader(self._pipe_r, read_callback)
81
+ try:
82
+ data = await future
83
+ finally:
84
+ loop.remove_reader(self._pipe_r)
85
+
86
+ if not data:
87
+ await asyncio.sleep(0.1)
88
+ continue
89
+
90
+ # Process the data
91
+ buffer += data
92
+
93
+ # Look for complete lines
94
+ while b"\n" in buffer:
95
+ line, buffer = buffer.split(b"\n", 1)
96
+ line_str = line.decode("utf-8", errors="replace")
97
+ if line_str:
98
+ self._captured_lines.append(line_str)
99
+ self.handler(line_str)
100
+
101
+ except asyncio.CancelledError:
102
+ # Process any remaining buffer
103
+ if buffer:
104
+ line_str = buffer.decode("utf-8", errors="replace")
105
+ if line_str:
106
+ self._captured_lines.append(line_str)
107
+ self.handler(line_str)
108
+ raise
109
+
110
+ async def stop_capture(self):
111
+ """Stop capturing and clean up."""
112
+ if self._reader_task:
113
+ self._reader_task.cancel()
114
+ try:
115
+ await self._reader_task
116
+ except asyncio.CancelledError:
117
+ pass
118
+
119
+ if self._pipe_r is not None:
120
+ os.close(self._pipe_r)
121
+ if self._pipe_w is not None:
122
+ os.close(self._pipe_w)
123
+
124
+ def get_captured_lines(self) -> list[str]:
125
+ """Get all captured lines."""
126
+ return self._captured_lines.copy()
127
+
128
+
129
+ class CapturedMCPServerStdio(MCPServerStdio):
130
+ """
131
+ Extended MCPServerStdio that captures and handles stderr output.
132
+
133
+ This class captures stderr from the subprocess and makes it available
134
+ through proper logging channels instead of letting it pollute the console.
135
+ """
136
+
137
+ def __init__(
138
+ self,
139
+ command: str,
140
+ args: Sequence[str] = (),
141
+ env: dict[str, str] | None = None,
142
+ cwd: str | None = None,
143
+ stderr_handler: Optional[callable] = None,
144
+ **kwargs,
145
+ ):
146
+ """
147
+ Initialize captured stdio server.
148
+
149
+ Args:
150
+ command: The command to run
151
+ args: Arguments for the command
152
+ env: Environment variables
153
+ cwd: Working directory
154
+ stderr_handler: Optional function to handle stderr lines
155
+ **kwargs: Additional arguments for MCPServerStdio
156
+ """
157
+ super().__init__(command=command, args=args, env=env, cwd=cwd, **kwargs)
158
+ self.stderr_handler = stderr_handler
159
+ self._stderr_capture = None
160
+ self._captured_lines = []
161
+
162
+ @asynccontextmanager
163
+ async def client_streams(
164
+ self,
165
+ ) -> AsyncIterator[
166
+ tuple[
167
+ MemoryObjectReceiveStream[SessionMessage | Exception],
168
+ MemoryObjectSendStream[SessionMessage],
169
+ ]
170
+ ]:
171
+ """Create the streams for the MCP server with stderr capture."""
172
+ server = StdioServerParameters(
173
+ command=self.command, args=list(self.args), env=self.env, cwd=self.cwd
174
+ )
175
+
176
+ # Create stderr capture
177
+ def stderr_line_handler(line: str):
178
+ """Handle captured stderr lines."""
179
+ self._captured_lines.append(line)
180
+
181
+ if self.stderr_handler:
182
+ self.stderr_handler(line)
183
+ else:
184
+ # Default: log at DEBUG level to avoid console spam
185
+ logger.debug(f"[MCP Server {self.command}] {line}")
186
+
187
+ self._stderr_capture = StderrCapture(self.command, stderr_line_handler)
188
+
189
+ # For now, use devnull for stderr to suppress output
190
+ # We'll capture it through other means if needed
191
+ with open(os.devnull, "w") as devnull:
192
+ async with stdio_client(server=server, errlog=devnull) as (
193
+ read_stream,
194
+ write_stream,
195
+ ):
196
+ yield read_stream, write_stream
197
+
198
+ def get_captured_stderr(self) -> list[str]:
199
+ """
200
+ Get all captured stderr lines.
201
+
202
+ Returns:
203
+ List of captured stderr lines
204
+ """
205
+ return self._captured_lines.copy()
206
+
207
+ def clear_captured_stderr(self):
208
+ """Clear the captured stderr buffer."""
209
+ self._captured_lines.clear()
210
+
211
+
212
+ class StderrCollector:
213
+ """
214
+ A centralized collector for stderr from multiple MCP servers.
215
+
216
+ This can be used to aggregate stderr from all MCP servers in one place.
217
+ """
218
+
219
+ def __init__(self):
220
+ """Initialize the collector."""
221
+ self.servers = {}
222
+ self.all_lines = []
223
+
224
+ def create_handler(self, server_name: str, emit_to_user: bool = False):
225
+ """
226
+ Create a handler function for a specific server.
227
+
228
+ Args:
229
+ server_name: Name to identify this server
230
+ emit_to_user: If True, emit stderr lines to user via emit_info
231
+
232
+ Returns:
233
+ Handler function that can be passed to CapturedMCPServerStdio
234
+ """
235
+
236
+ def handler(line: str):
237
+ # Store with server identification
238
+ import time
239
+
240
+ entry = {"server": server_name, "line": line, "timestamp": time.time()}
241
+
242
+ if server_name not in self.servers:
243
+ self.servers[server_name] = []
244
+
245
+ self.servers[server_name].append(line)
246
+ self.all_lines.append(entry)
247
+
248
+ # Emit to user if requested
249
+ if emit_to_user:
250
+ from code_puppy.messaging import emit_info
251
+
252
+ emit_info(f"MCP {server_name}: {line}")
253
+
254
+ return handler
255
+
256
+ def get_server_output(self, server_name: str) -> list[str]:
257
+ """Get all output from a specific server."""
258
+ return self.servers.get(server_name, []).copy()
259
+
260
+ def get_all_output(self) -> list[dict]:
261
+ """Get all output from all servers with metadata."""
262
+ return self.all_lines.copy()
263
+
264
+ def clear(self, server_name: Optional[str] = None):
265
+ """Clear captured output."""
266
+ if server_name:
267
+ if server_name in self.servers:
268
+ del self.servers[server_name]
269
+ # Also clear from all_lines
270
+ self.all_lines = [
271
+ entry for entry in self.all_lines if entry["server"] != server_name
272
+ ]
273
+ else:
274
+ self.servers.clear()
275
+ self.all_lines.clear()
@@ -0,0 +1,290 @@
1
+ """
2
+ Circuit breaker implementation for MCP servers to prevent cascading failures.
3
+
4
+ This module implements the circuit breaker pattern to protect against cascading
5
+ failures when MCP servers become unhealthy. The circuit breaker has three states:
6
+ - CLOSED: Normal operation, calls pass through
7
+ - OPEN: Calls are blocked and fail fast
8
+ - HALF_OPEN: Limited calls allowed to test recovery
9
+ """
10
+
11
+ import asyncio
12
+ import logging
13
+ import threading
14
+ import time
15
+ from enum import Enum
16
+ from typing import Any, Callable
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class CircuitState(Enum):
22
+ """Circuit breaker states."""
23
+
24
+ CLOSED = "closed" # Normal operation
25
+ OPEN = "open" # Blocking calls
26
+ HALF_OPEN = "half_open" # Testing recovery
27
+
28
+
29
+ class CircuitOpenError(Exception):
30
+ """Raised when circuit breaker is in OPEN state."""
31
+
32
+ pass
33
+
34
+
35
+ class CircuitBreaker:
36
+ """
37
+ Circuit breaker to prevent cascading failures in MCP servers.
38
+
39
+ The circuit breaker monitors the success/failure rate of operations and
40
+ transitions between states to protect the system from unhealthy dependencies.
41
+
42
+ States:
43
+ - CLOSED: Normal operation, all calls allowed
44
+ - OPEN: Circuit is open, all calls fail fast with CircuitOpenError
45
+ - HALF_OPEN: Testing recovery, limited calls allowed
46
+
47
+ State Transitions:
48
+ - CLOSED → OPEN: After failure_threshold consecutive failures
49
+ - OPEN → HALF_OPEN: After timeout seconds
50
+ - HALF_OPEN → CLOSED: After success_threshold consecutive successes
51
+ - HALF_OPEN → OPEN: After any failure
52
+ """
53
+
54
+ def __init__(
55
+ self, failure_threshold: int = 5, success_threshold: int = 2, timeout: int = 60
56
+ ):
57
+ """
58
+ Initialize circuit breaker.
59
+
60
+ Args:
61
+ failure_threshold: Number of consecutive failures before opening circuit
62
+ success_threshold: Number of consecutive successes needed to close circuit from half-open
63
+ timeout: Seconds to wait before transitioning from OPEN to HALF_OPEN
64
+ """
65
+ self.failure_threshold = failure_threshold
66
+ self.success_threshold = success_threshold
67
+ self.timeout = timeout
68
+
69
+ self._state = CircuitState.CLOSED
70
+ self._failure_count = 0
71
+ self._success_count = 0
72
+ self._last_failure_time = None
73
+ # NOTE: We use threading.Lock (not asyncio.Lock) because this lock is shared
74
+ # between synchronous callers (record_success/record_failure) and async callers
75
+ # (_on_success/_on_failure called from call()). This is safe because the critical
76
+ # sections are very short and CPU-bound only (counter increments, state transitions)
77
+ # — no I/O or awaits occur while the lock is held, so event loop blocking is negligible.
78
+ self._sync_lock = threading.Lock()
79
+ self._half_open_in_flight = False
80
+
81
+ logger.info(
82
+ f"Circuit breaker initialized: failure_threshold={failure_threshold}, "
83
+ f"success_threshold={success_threshold}, timeout={timeout}s"
84
+ )
85
+
86
+ async def call(self, func: Callable, *args, **kwargs) -> Any:
87
+ """
88
+ Execute a function through the circuit breaker.
89
+
90
+ Args:
91
+ func: Function to execute
92
+ *args: Positional arguments for the function
93
+ **kwargs: Keyword arguments for the function
94
+
95
+ Returns:
96
+ Result of the function call
97
+
98
+ Raises:
99
+ CircuitOpenError: If circuit is in OPEN state
100
+ Exception: Any exception raised by the wrapped function
101
+ """
102
+ with self._sync_lock:
103
+ current_state = self._get_current_state()
104
+
105
+ if current_state == CircuitState.OPEN:
106
+ logger.warning("Circuit breaker is OPEN, failing fast")
107
+ raise CircuitOpenError("Circuit breaker is open")
108
+
109
+ if current_state == CircuitState.HALF_OPEN:
110
+ if self._half_open_in_flight:
111
+ logger.warning(
112
+ "Circuit breaker HALF_OPEN with call already in flight, failing fast"
113
+ )
114
+ raise CircuitOpenError(
115
+ "Circuit breaker half-open test call already in flight"
116
+ )
117
+ # In half-open state, we're testing recovery
118
+ logger.info("Circuit breaker is HALF_OPEN, allowing test call")
119
+ self._half_open_in_flight = True
120
+
121
+ checked_state = current_state
122
+
123
+ # Execute the function outside the lock to avoid blocking other calls
124
+ try:
125
+ result = (
126
+ await func(*args, **kwargs)
127
+ if asyncio.iscoroutinefunction(func)
128
+ else func(*args, **kwargs)
129
+ )
130
+ await self._on_success(checked_state=checked_state)
131
+ return result
132
+ except Exception:
133
+ await self._on_failure(checked_state=checked_state)
134
+ raise
135
+
136
+ def record_success(self) -> None:
137
+ """Record a successful operation (synchronous)."""
138
+ with self._sync_lock:
139
+ self._on_success_sync()
140
+
141
+ def record_failure(self) -> None:
142
+ """Record a failed operation (synchronous)."""
143
+ with self._sync_lock:
144
+ self._on_failure_sync()
145
+
146
+ def get_state(self) -> CircuitState:
147
+ """Get current circuit breaker state."""
148
+ with self._sync_lock:
149
+ return self._get_current_state()
150
+
151
+ def is_open(self) -> bool:
152
+ """Check if circuit breaker is in OPEN state."""
153
+ with self._sync_lock:
154
+ return self._get_current_state() == CircuitState.OPEN
155
+
156
+ def is_half_open(self) -> bool:
157
+ """Check if circuit breaker is in HALF_OPEN state."""
158
+ with self._sync_lock:
159
+ return self._get_current_state() == CircuitState.HALF_OPEN
160
+
161
+ def is_closed(self) -> bool:
162
+ """Check if circuit breaker is in CLOSED state."""
163
+ with self._sync_lock:
164
+ return self._get_current_state() == CircuitState.CLOSED
165
+
166
+ def reset(self) -> None:
167
+ """Reset circuit breaker to CLOSED state and clear counters."""
168
+ with self._sync_lock:
169
+ logger.info("Resetting circuit breaker to CLOSED state")
170
+ self._state = CircuitState.CLOSED
171
+ self._failure_count = 0
172
+ self._success_count = 0
173
+ self._last_failure_time = None
174
+ self._half_open_in_flight = False
175
+
176
+ def force_open(self) -> None:
177
+ """Force circuit breaker to OPEN state."""
178
+ with self._sync_lock:
179
+ logger.warning("Forcing circuit breaker to OPEN state")
180
+ self._state = CircuitState.OPEN
181
+ self._last_failure_time = time.time()
182
+ self._half_open_in_flight = False
183
+
184
+ def force_close(self) -> None:
185
+ """Force circuit breaker to CLOSED state and reset counters."""
186
+ with self._sync_lock:
187
+ logger.info("Forcing circuit breaker to CLOSED state")
188
+ self._state = CircuitState.CLOSED
189
+ self._failure_count = 0
190
+ self._success_count = 0
191
+ self._last_failure_time = None
192
+ self._half_open_in_flight = False
193
+
194
+ def _get_current_state(self) -> CircuitState:
195
+ """
196
+ Get the current state, handling automatic transitions.
197
+
198
+ This method handles the automatic transition from OPEN to HALF_OPEN
199
+ after the timeout period has elapsed.
200
+ """
201
+ if self._state == CircuitState.OPEN and self._should_attempt_reset():
202
+ logger.info("Timeout reached, transitioning from OPEN to HALF_OPEN")
203
+ self._state = CircuitState.HALF_OPEN
204
+ self._success_count = 0 # Reset success counter for half-open testing
205
+
206
+ return self._state
207
+
208
+ def _should_attempt_reset(self) -> bool:
209
+ """Check if enough time has passed to attempt reset from OPEN to HALF_OPEN."""
210
+ if self._last_failure_time is None:
211
+ return False
212
+
213
+ return time.time() - self._last_failure_time >= self.timeout
214
+
215
+ def _on_success_sync(self, checked_state: CircuitState | None = None) -> None:
216
+ """Handle successful operation (synchronous, no lock)."""
217
+ current_state = (
218
+ checked_state if checked_state is not None else self._get_current_state()
219
+ )
220
+
221
+ if current_state == CircuitState.CLOSED:
222
+ if self._failure_count > 0:
223
+ logger.debug("Resetting failure count after success")
224
+ self._failure_count = 0
225
+
226
+ elif current_state == CircuitState.HALF_OPEN:
227
+ self._success_count += 1
228
+ logger.debug(
229
+ f"Success in HALF_OPEN state: {self._success_count}/{self.success_threshold}"
230
+ )
231
+
232
+ self._half_open_in_flight = False
233
+
234
+ if self._success_count >= self.success_threshold:
235
+ logger.info(
236
+ "Success threshold reached, transitioning from HALF_OPEN to CLOSED"
237
+ )
238
+ self._state = CircuitState.CLOSED
239
+ self._failure_count = 0
240
+ self._success_count = 0
241
+ self._last_failure_time = None
242
+ self._half_open_in_flight = False
243
+
244
+ def _on_failure_sync(self, checked_state: CircuitState | None = None) -> None:
245
+ """Handle failed operation (synchronous, no lock)."""
246
+ current_state = (
247
+ checked_state if checked_state is not None else self._get_current_state()
248
+ )
249
+
250
+ if current_state == CircuitState.CLOSED:
251
+ self._failure_count += 1
252
+ logger.debug(
253
+ f"Failure in CLOSED state: {self._failure_count}/{self.failure_threshold}"
254
+ )
255
+
256
+ if self._failure_count >= self.failure_threshold:
257
+ logger.warning(
258
+ "Failure threshold reached, transitioning from CLOSED to OPEN"
259
+ )
260
+ self._state = CircuitState.OPEN
261
+ self._last_failure_time = time.time()
262
+
263
+ elif current_state == CircuitState.HALF_OPEN:
264
+ logger.warning("Failure in HALF_OPEN state, transitioning back to OPEN")
265
+ self._state = CircuitState.OPEN
266
+ self._success_count = 0
267
+ self._last_failure_time = time.time()
268
+ self._half_open_in_flight = False
269
+
270
+ async def _on_success(self, checked_state: CircuitState | None = None) -> None:
271
+ """Handle successful operation.
272
+
273
+ This method is async to match the await call-site in call(), but the
274
+ underlying work is purely synchronous. We acquire threading.Lock (not
275
+ asyncio.Lock) because the same state is accessed from sync contexts
276
+ (record_success). The critical section is short and CPU-bound, so
277
+ holding a threading.Lock in an async method does not meaningfully
278
+ block the event loop.
279
+ """
280
+ with self._sync_lock:
281
+ self._on_success_sync(checked_state=checked_state)
282
+
283
+ async def _on_failure(self, checked_state: CircuitState | None = None) -> None:
284
+ """Handle failed operation.
285
+
286
+ See _on_success docstring for rationale on threading.Lock usage in
287
+ an async method.
288
+ """
289
+ with self._sync_lock:
290
+ self._on_failure_sync(checked_state=checked_state)