code-puppy 0.0.169__py3-none-any.whl → 0.0.366__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 (243) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +8 -8
  3. code_puppy/agents/agent_c_reviewer.py +155 -0
  4. code_puppy/agents/agent_code_puppy.py +9 -2
  5. code_puppy/agents/agent_code_reviewer.py +90 -0
  6. code_puppy/agents/agent_cpp_reviewer.py +132 -0
  7. code_puppy/agents/agent_creator_agent.py +48 -9
  8. code_puppy/agents/agent_golang_reviewer.py +151 -0
  9. code_puppy/agents/agent_javascript_reviewer.py +160 -0
  10. code_puppy/agents/agent_manager.py +146 -199
  11. code_puppy/agents/agent_pack_leader.py +383 -0
  12. code_puppy/agents/agent_planning.py +163 -0
  13. code_puppy/agents/agent_python_programmer.py +165 -0
  14. code_puppy/agents/agent_python_reviewer.py +90 -0
  15. code_puppy/agents/agent_qa_expert.py +163 -0
  16. code_puppy/agents/agent_qa_kitten.py +208 -0
  17. code_puppy/agents/agent_security_auditor.py +181 -0
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +166 -0
  20. code_puppy/agents/base_agent.py +1713 -1
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/json_agent.py +12 -1
  23. code_puppy/agents/pack/__init__.py +34 -0
  24. code_puppy/agents/pack/bloodhound.py +304 -0
  25. code_puppy/agents/pack/husky.py +321 -0
  26. code_puppy/agents/pack/retriever.py +393 -0
  27. code_puppy/agents/pack/shepherd.py +348 -0
  28. code_puppy/agents/pack/terrier.py +287 -0
  29. code_puppy/agents/pack/watchdog.py +367 -0
  30. code_puppy/agents/prompt_reviewer.py +145 -0
  31. code_puppy/agents/subagent_stream_handler.py +276 -0
  32. code_puppy/api/__init__.py +13 -0
  33. code_puppy/api/app.py +169 -0
  34. code_puppy/api/main.py +21 -0
  35. code_puppy/api/pty_manager.py +446 -0
  36. code_puppy/api/routers/__init__.py +12 -0
  37. code_puppy/api/routers/agents.py +36 -0
  38. code_puppy/api/routers/commands.py +217 -0
  39. code_puppy/api/routers/config.py +74 -0
  40. code_puppy/api/routers/sessions.py +232 -0
  41. code_puppy/api/templates/terminal.html +361 -0
  42. code_puppy/api/websocket.py +154 -0
  43. code_puppy/callbacks.py +174 -4
  44. code_puppy/chatgpt_codex_client.py +283 -0
  45. code_puppy/claude_cache_client.py +586 -0
  46. code_puppy/cli_runner.py +916 -0
  47. code_puppy/command_line/add_model_menu.py +1079 -0
  48. code_puppy/command_line/agent_menu.py +395 -0
  49. code_puppy/command_line/attachments.py +395 -0
  50. code_puppy/command_line/autosave_menu.py +605 -0
  51. code_puppy/command_line/clipboard.py +527 -0
  52. code_puppy/command_line/colors_menu.py +520 -0
  53. code_puppy/command_line/command_handler.py +233 -627
  54. code_puppy/command_line/command_registry.py +150 -0
  55. code_puppy/command_line/config_commands.py +715 -0
  56. code_puppy/command_line/core_commands.py +792 -0
  57. code_puppy/command_line/diff_menu.py +863 -0
  58. code_puppy/command_line/load_context_completion.py +15 -22
  59. code_puppy/command_line/mcp/base.py +1 -4
  60. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  61. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  62. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  63. code_puppy/command_line/mcp/edit_command.py +148 -0
  64. code_puppy/command_line/mcp/handler.py +9 -4
  65. code_puppy/command_line/mcp/help_command.py +6 -5
  66. code_puppy/command_line/mcp/install_command.py +16 -27
  67. code_puppy/command_line/mcp/install_menu.py +685 -0
  68. code_puppy/command_line/mcp/list_command.py +3 -3
  69. code_puppy/command_line/mcp/logs_command.py +174 -65
  70. code_puppy/command_line/mcp/remove_command.py +2 -2
  71. code_puppy/command_line/mcp/restart_command.py +12 -4
  72. code_puppy/command_line/mcp/search_command.py +17 -11
  73. code_puppy/command_line/mcp/start_all_command.py +22 -13
  74. code_puppy/command_line/mcp/start_command.py +50 -31
  75. code_puppy/command_line/mcp/status_command.py +6 -7
  76. code_puppy/command_line/mcp/stop_all_command.py +11 -8
  77. code_puppy/command_line/mcp/stop_command.py +11 -10
  78. code_puppy/command_line/mcp/test_command.py +2 -2
  79. code_puppy/command_line/mcp/utils.py +1 -1
  80. code_puppy/command_line/mcp/wizard_utils.py +22 -18
  81. code_puppy/command_line/mcp_completion.py +174 -0
  82. code_puppy/command_line/model_picker_completion.py +89 -30
  83. code_puppy/command_line/model_settings_menu.py +884 -0
  84. code_puppy/command_line/motd.py +14 -8
  85. code_puppy/command_line/onboarding_slides.py +179 -0
  86. code_puppy/command_line/onboarding_wizard.py +340 -0
  87. code_puppy/command_line/pin_command_completion.py +329 -0
  88. code_puppy/command_line/prompt_toolkit_completion.py +626 -75
  89. code_puppy/command_line/session_commands.py +296 -0
  90. code_puppy/command_line/utils.py +54 -0
  91. code_puppy/config.py +1181 -51
  92. code_puppy/error_logging.py +118 -0
  93. code_puppy/gemini_code_assist.py +385 -0
  94. code_puppy/gemini_model.py +602 -0
  95. code_puppy/http_utils.py +220 -104
  96. code_puppy/keymap.py +128 -0
  97. code_puppy/main.py +5 -594
  98. code_puppy/{mcp → mcp_}/__init__.py +17 -0
  99. code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
  100. code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
  101. code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
  102. code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
  103. code_puppy/{mcp → mcp_}/dashboard.py +15 -6
  104. code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
  105. code_puppy/{mcp → mcp_}/managed_server.py +66 -39
  106. code_puppy/{mcp → mcp_}/manager.py +146 -52
  107. code_puppy/mcp_/mcp_logs.py +224 -0
  108. code_puppy/{mcp → mcp_}/registry.py +6 -6
  109. code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
  110. code_puppy/messaging/__init__.py +199 -2
  111. code_puppy/messaging/bus.py +610 -0
  112. code_puppy/messaging/commands.py +167 -0
  113. code_puppy/messaging/markdown_patches.py +57 -0
  114. code_puppy/messaging/message_queue.py +17 -48
  115. code_puppy/messaging/messages.py +500 -0
  116. code_puppy/messaging/queue_console.py +1 -24
  117. code_puppy/messaging/renderers.py +43 -146
  118. code_puppy/messaging/rich_renderer.py +1027 -0
  119. code_puppy/messaging/spinner/__init__.py +33 -5
  120. code_puppy/messaging/spinner/console_spinner.py +92 -52
  121. code_puppy/messaging/spinner/spinner_base.py +29 -0
  122. code_puppy/messaging/subagent_console.py +461 -0
  123. code_puppy/model_factory.py +686 -80
  124. code_puppy/model_utils.py +167 -0
  125. code_puppy/models.json +86 -104
  126. code_puppy/models_dev_api.json +1 -0
  127. code_puppy/models_dev_parser.py +592 -0
  128. code_puppy/plugins/__init__.py +164 -10
  129. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  130. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  131. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  132. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  133. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  134. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  135. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  136. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  137. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  138. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  139. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  140. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  141. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  142. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  143. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  144. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  145. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  146. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  147. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  148. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  149. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  150. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  151. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  152. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  153. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  154. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  155. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  156. code_puppy/plugins/example_custom_command/README.md +280 -0
  157. code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
  158. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  159. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  160. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  161. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  162. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  163. code_puppy/plugins/oauth_puppy_html.py +228 -0
  164. code_puppy/plugins/shell_safety/__init__.py +6 -0
  165. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  166. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  167. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  168. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  169. code_puppy/prompts/codex_system_prompt.md +310 -0
  170. code_puppy/pydantic_patches.py +131 -0
  171. code_puppy/reopenable_async_client.py +8 -8
  172. code_puppy/round_robin_model.py +10 -15
  173. code_puppy/session_storage.py +294 -0
  174. code_puppy/status_display.py +21 -4
  175. code_puppy/summarization_agent.py +52 -14
  176. code_puppy/terminal_utils.py +418 -0
  177. code_puppy/tools/__init__.py +139 -6
  178. code_puppy/tools/agent_tools.py +548 -49
  179. code_puppy/tools/browser/__init__.py +37 -0
  180. code_puppy/tools/browser/browser_control.py +289 -0
  181. code_puppy/tools/browser/browser_interactions.py +545 -0
  182. code_puppy/tools/browser/browser_locators.py +640 -0
  183. code_puppy/tools/browser/browser_manager.py +316 -0
  184. code_puppy/tools/browser/browser_navigation.py +251 -0
  185. code_puppy/tools/browser/browser_screenshot.py +179 -0
  186. code_puppy/tools/browser/browser_scripts.py +462 -0
  187. code_puppy/tools/browser/browser_workflows.py +221 -0
  188. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  189. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  190. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  191. code_puppy/tools/browser/terminal_tools.py +525 -0
  192. code_puppy/tools/command_runner.py +941 -153
  193. code_puppy/tools/common.py +1146 -6
  194. code_puppy/tools/display.py +84 -0
  195. code_puppy/tools/file_modifications.py +288 -89
  196. code_puppy/tools/file_operations.py +352 -266
  197. code_puppy/tools/subagent_context.py +158 -0
  198. code_puppy/uvx_detection.py +242 -0
  199. code_puppy/version_checker.py +30 -11
  200. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  201. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  202. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
  203. code_puppy-0.0.366.dist-info/RECORD +217 -0
  204. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  205. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
  206. code_puppy/agent.py +0 -231
  207. code_puppy/agents/agent_orchestrator.json +0 -26
  208. code_puppy/agents/runtime_manager.py +0 -272
  209. code_puppy/command_line/mcp/add_command.py +0 -183
  210. code_puppy/command_line/meta_command_handler.py +0 -153
  211. code_puppy/message_history_processor.py +0 -490
  212. code_puppy/messaging/spinner/textual_spinner.py +0 -101
  213. code_puppy/state_management.py +0 -200
  214. code_puppy/tui/__init__.py +0 -10
  215. code_puppy/tui/app.py +0 -986
  216. code_puppy/tui/components/__init__.py +0 -21
  217. code_puppy/tui/components/chat_view.py +0 -550
  218. code_puppy/tui/components/command_history_modal.py +0 -218
  219. code_puppy/tui/components/copy_button.py +0 -139
  220. code_puppy/tui/components/custom_widgets.py +0 -63
  221. code_puppy/tui/components/human_input_modal.py +0 -175
  222. code_puppy/tui/components/input_area.py +0 -167
  223. code_puppy/tui/components/sidebar.py +0 -309
  224. code_puppy/tui/components/status_bar.py +0 -182
  225. code_puppy/tui/messages.py +0 -27
  226. code_puppy/tui/models/__init__.py +0 -8
  227. code_puppy/tui/models/chat_message.py +0 -25
  228. code_puppy/tui/models/command_history.py +0 -89
  229. code_puppy/tui/models/enums.py +0 -24
  230. code_puppy/tui/screens/__init__.py +0 -15
  231. code_puppy/tui/screens/help.py +0 -130
  232. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  233. code_puppy/tui/screens/settings.py +0 -290
  234. code_puppy/tui/screens/tools.py +0 -74
  235. code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
  236. code_puppy-0.0.169.dist-info/RECORD +0 -112
  237. /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
  238. /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
  239. /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
  240. /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
  241. /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
  242. /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
  243. {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,154 @@
1
+ """WebSocket endpoints for Code Puppy API.
2
+
3
+ Provides real-time communication channels:
4
+ - /ws/events - Server-sent events stream
5
+ - /ws/terminal - Interactive PTY terminal sessions
6
+ - /ws/health - Simple health check endpoint
7
+ """
8
+
9
+ import asyncio
10
+ import base64
11
+ import logging
12
+ import uuid
13
+
14
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def setup_websocket(app: FastAPI) -> None:
20
+ """Setup WebSocket endpoints for the application."""
21
+
22
+ @app.websocket("/ws/events")
23
+ async def websocket_events(websocket: WebSocket) -> None:
24
+ """Stream real-time events to connected clients."""
25
+ await websocket.accept()
26
+ logger.info("Events WebSocket client connected")
27
+
28
+ from code_puppy.plugins.frontend_emitter.emitter import (
29
+ get_recent_events,
30
+ subscribe,
31
+ unsubscribe,
32
+ )
33
+
34
+ event_queue = subscribe()
35
+
36
+ try:
37
+ recent_events = get_recent_events()
38
+ for event in recent_events:
39
+ await websocket.send_json(event)
40
+
41
+ while True:
42
+ try:
43
+ event = await asyncio.wait_for(event_queue.get(), timeout=30.0)
44
+ await websocket.send_json(event)
45
+ except asyncio.TimeoutError:
46
+ try:
47
+ await websocket.send_json({"type": "ping"})
48
+ except Exception:
49
+ break
50
+ except WebSocketDisconnect:
51
+ logger.info("Events WebSocket client disconnected")
52
+ except Exception as e:
53
+ logger.error(f"Events WebSocket error: {e}")
54
+ finally:
55
+ unsubscribe(event_queue)
56
+
57
+ @app.websocket("/ws/terminal")
58
+ async def websocket_terminal(websocket: WebSocket) -> None:
59
+ """Interactive terminal WebSocket endpoint."""
60
+ await websocket.accept()
61
+ logger.info("Terminal WebSocket client connected")
62
+
63
+ from code_puppy.api.pty_manager import get_pty_manager
64
+
65
+ manager = get_pty_manager()
66
+ session_id = str(uuid.uuid4())[:8]
67
+ session = None
68
+
69
+ # Get the current event loop for thread-safe scheduling
70
+ loop = asyncio.get_running_loop()
71
+
72
+ # Queue to receive PTY output in a thread-safe way
73
+ output_queue: asyncio.Queue[bytes] = asyncio.Queue()
74
+
75
+ # Output callback - called from thread pool, puts data in queue
76
+ def on_output(data: bytes) -> None:
77
+ try:
78
+ loop.call_soon_threadsafe(output_queue.put_nowait, data)
79
+ except Exception as e:
80
+ logger.error(f"on_output error: {e}")
81
+
82
+ async def output_sender() -> None:
83
+ """Coroutine that sends queued output to WebSocket."""
84
+ try:
85
+ while True:
86
+ data = await output_queue.get()
87
+ await websocket.send_json(
88
+ {
89
+ "type": "output",
90
+ "data": base64.b64encode(data).decode("ascii"),
91
+ }
92
+ )
93
+ except asyncio.CancelledError:
94
+ pass
95
+ except Exception as e:
96
+ logger.error(f"output_sender error: {e}")
97
+
98
+ sender_task = None
99
+
100
+ try:
101
+ # Create PTY session
102
+ session = await manager.create_session(
103
+ session_id=session_id,
104
+ on_output=on_output,
105
+ )
106
+
107
+ # Send session info
108
+ await websocket.send_json({"type": "session", "id": session_id})
109
+
110
+ # Start output sender task
111
+ sender_task = asyncio.create_task(output_sender())
112
+
113
+ # Handle incoming messages
114
+ while True:
115
+ try:
116
+ msg = await websocket.receive_json()
117
+
118
+ if msg.get("type") == "input":
119
+ data = msg.get("data", "")
120
+ if isinstance(data, str):
121
+ data = data.encode("utf-8")
122
+ await manager.write(session_id, data)
123
+ elif msg.get("type") == "resize":
124
+ cols = msg.get("cols", 80)
125
+ rows = msg.get("rows", 24)
126
+ await manager.resize(session_id, cols, rows)
127
+ except WebSocketDisconnect:
128
+ break
129
+ except Exception as e:
130
+ logger.error(f"Terminal WebSocket error: {e}")
131
+ break
132
+ except Exception as e:
133
+ logger.error(f"Terminal session error: {e}")
134
+ finally:
135
+ if sender_task:
136
+ sender_task.cancel()
137
+ try:
138
+ await sender_task
139
+ except asyncio.CancelledError:
140
+ pass
141
+ if session:
142
+ await manager.close_session(session_id)
143
+ logger.info("Terminal WebSocket disconnected")
144
+
145
+ @app.websocket("/ws/health")
146
+ async def websocket_health(websocket: WebSocket) -> None:
147
+ """Simple WebSocket health check - echoes messages back."""
148
+ await websocket.accept()
149
+ try:
150
+ while True:
151
+ data = await websocket.receive_text()
152
+ await websocket.send_text(f"echo: {data}")
153
+ except WebSocketDisconnect:
154
+ pass
code_puppy/callbacks.py CHANGED
@@ -15,6 +15,12 @@ PhaseType = Literal[
15
15
  "load_model_config",
16
16
  "load_prompt",
17
17
  "agent_reload",
18
+ "custom_command",
19
+ "custom_command_help",
20
+ "file_permission",
21
+ "pre_tool_call",
22
+ "post_tool_call",
23
+ "stream_event",
18
24
  ]
19
25
  CallbackFunc = Callable[..., Any]
20
26
 
@@ -30,6 +36,12 @@ _callbacks: Dict[PhaseType, List[CallbackFunc]] = {
30
36
  "load_model_config": [],
31
37
  "load_prompt": [],
32
38
  "agent_reload": [],
39
+ "custom_command": [],
40
+ "custom_command_help": [],
41
+ "file_permission": [],
42
+ "pre_tool_call": [],
43
+ "post_tool_call": [],
44
+ "stream_event": [],
33
45
  }
34
46
 
35
47
  logger = logging.getLogger(__name__)
@@ -44,6 +56,14 @@ def register_callback(phase: PhaseType, func: CallbackFunc) -> None:
44
56
  if not callable(func):
45
57
  raise TypeError(f"Callback must be callable, got {type(func)}")
46
58
 
59
+ # Prevent duplicate registration of the same callback function
60
+ # This can happen if plugins are accidentally loaded multiple times
61
+ if func in _callbacks[phase]:
62
+ logger.debug(
63
+ f"Callback {func.__name__} already registered for phase '{phase}', skipping"
64
+ )
65
+ return
66
+
47
67
  _callbacks[phase].append(func)
48
68
  logger.debug(f"Registered async callback {func.__name__} for phase '{phase}'")
49
69
 
@@ -93,11 +113,27 @@ def _trigger_callbacks_sync(phase: PhaseType, *args, **kwargs) -> List[Any]:
93
113
  for callback in callbacks:
94
114
  try:
95
115
  result = callback(*args, **kwargs)
116
+ # Handle async callbacks - if we get a coroutine, run it
117
+ if asyncio.iscoroutine(result):
118
+ # Try to get the running event loop
119
+ try:
120
+ asyncio.get_running_loop()
121
+ # We're in an async context already - this shouldn't happen for sync triggers
122
+ # but if it does, we can't use run_until_complete
123
+ logger.warning(
124
+ f"Async callback {callback.__name__} called from async context in sync trigger"
125
+ )
126
+ results.append(None)
127
+ continue
128
+ except RuntimeError:
129
+ # No running loop - we're in a sync/worker thread context
130
+ # Use asyncio.run() which is safe here since we're in an isolated thread
131
+ result = asyncio.run(result)
96
132
  results.append(result)
97
- logger.debug(f"Successfully executed async callback {callback.__name__}")
133
+ logger.debug(f"Successfully executed callback {callback.__name__}")
98
134
  except Exception as e:
99
135
  logger.error(
100
- f"Async callback {callback.__name__} failed in phase '{phase}': {e}\n"
136
+ f"Callback {callback.__name__} failed in phase '{phase}': {e}\n"
101
137
  f"{traceback.format_exc()}"
102
138
  )
103
139
  results.append(None)
@@ -164,8 +200,8 @@ def on_delete_file(*args, **kwargs) -> Any:
164
200
  return _trigger_callbacks_sync("delete_file", *args, **kwargs)
165
201
 
166
202
 
167
- def on_run_shell_command(*args, **kwargs) -> Any:
168
- return _trigger_callbacks_sync("run_shell_command", *args, **kwargs)
203
+ async def on_run_shell_command(*args, **kwargs) -> Any:
204
+ return await _trigger_callbacks("run_shell_command", *args, **kwargs)
169
205
 
170
206
 
171
207
  def on_agent_reload(*args, **kwargs) -> Any:
@@ -174,3 +210,137 @@ def on_agent_reload(*args, **kwargs) -> Any:
174
210
 
175
211
  def on_load_prompt():
176
212
  return _trigger_callbacks_sync("load_prompt")
213
+
214
+
215
+ def on_custom_command_help() -> List[Any]:
216
+ """Collect custom command help entries from plugins.
217
+
218
+ Each callback should return a list of tuples [(name, description), ...]
219
+ or a single tuple, or None. We'll flatten and sanitize results.
220
+ """
221
+ return _trigger_callbacks_sync("custom_command_help")
222
+
223
+
224
+ def on_custom_command(command: str, name: str) -> List[Any]:
225
+ """Trigger custom command callbacks.
226
+
227
+ This allows plugins to register handlers for slash commands
228
+ that are not built into the core command handler.
229
+
230
+ Args:
231
+ command: The full command string (e.g., "/foo bar baz").
232
+ name: The primary command name without the leading slash (e.g., "foo").
233
+
234
+ Returns:
235
+ Implementations may return:
236
+ - True if the command was handled (and no further action is needed)
237
+ - A string to be processed as user input by the caller
238
+ - None to indicate not handled
239
+ """
240
+ return _trigger_callbacks_sync("custom_command", command, name)
241
+
242
+
243
+ def on_file_permission(
244
+ context: Any,
245
+ file_path: str,
246
+ operation: str,
247
+ preview: str | None = None,
248
+ message_group: str | None = None,
249
+ operation_data: Any = None,
250
+ ) -> List[Any]:
251
+ """Trigger file permission callbacks.
252
+
253
+ This allows plugins to register handlers for file permission checks
254
+ before file operations are performed.
255
+
256
+ Args:
257
+ context: The operation context
258
+ file_path: Path to the file being operated on
259
+ operation: Description of the operation
260
+ preview: Optional preview of changes (deprecated - use operation_data instead)
261
+ message_group: Optional message group
262
+ operation_data: Operation-specific data for preview generation (recommended)
263
+
264
+ Returns:
265
+ List of boolean results from permission handlers.
266
+ Returns True if permission should be granted, False if denied.
267
+ """
268
+ # For backward compatibility, if operation_data is provided, prefer it over preview
269
+ if operation_data is not None:
270
+ preview = None
271
+ return _trigger_callbacks_sync(
272
+ "file_permission",
273
+ context,
274
+ file_path,
275
+ operation,
276
+ preview,
277
+ message_group,
278
+ operation_data,
279
+ )
280
+
281
+
282
+ async def on_pre_tool_call(
283
+ tool_name: str, tool_args: dict, context: Any = None
284
+ ) -> List[Any]:
285
+ """Trigger callbacks before a tool is called.
286
+
287
+ This allows plugins to inspect, modify, or log tool calls before
288
+ they are executed.
289
+
290
+ Args:
291
+ tool_name: Name of the tool being called
292
+ tool_args: Arguments being passed to the tool
293
+ context: Optional context data for the tool call
294
+
295
+ Returns:
296
+ List of results from registered callbacks.
297
+ """
298
+ return await _trigger_callbacks("pre_tool_call", tool_name, tool_args, context)
299
+
300
+
301
+ async def on_post_tool_call(
302
+ tool_name: str,
303
+ tool_args: dict,
304
+ result: Any,
305
+ duration_ms: float,
306
+ context: Any = None,
307
+ ) -> List[Any]:
308
+ """Trigger callbacks after a tool completes.
309
+
310
+ This allows plugins to inspect tool results, log execution times,
311
+ or perform post-processing.
312
+
313
+ Args:
314
+ tool_name: Name of the tool that was called
315
+ tool_args: Arguments that were passed to the tool
316
+ result: The result returned by the tool
317
+ duration_ms: Execution time in milliseconds
318
+ context: Optional context data for the tool call
319
+
320
+ Returns:
321
+ List of results from registered callbacks.
322
+ """
323
+ return await _trigger_callbacks(
324
+ "post_tool_call", tool_name, tool_args, result, duration_ms, context
325
+ )
326
+
327
+
328
+ async def on_stream_event(
329
+ event_type: str, event_data: Any, agent_session_id: str | None = None
330
+ ) -> List[Any]:
331
+ """Trigger callbacks for streaming events.
332
+
333
+ This allows plugins to react to streaming events in real-time,
334
+ such as tokens being generated, tool calls starting, etc.
335
+
336
+ Args:
337
+ event_type: Type of the streaming event
338
+ event_data: Data associated with the event
339
+ agent_session_id: Optional session ID of the agent emitting the event
340
+
341
+ Returns:
342
+ List of results from registered callbacks.
343
+ """
344
+ return await _trigger_callbacks(
345
+ "stream_event", event_type, event_data, agent_session_id
346
+ )
@@ -0,0 +1,283 @@
1
+ """HTTP client interceptor for ChatGPT Codex API.
2
+
3
+ ChatGPTCodexAsyncClient: httpx client that injects required fields into
4
+ request bodies for the ChatGPT Codex API and handles stream-to-non-stream
5
+ conversion.
6
+
7
+ The Codex API requires:
8
+ - "store": false - Disables conversation storage
9
+ - "stream": true - Streaming is mandatory
10
+
11
+ Removes unsupported parameters:
12
+ - "max_output_tokens" - Not supported by Codex API
13
+ - "max_tokens" - Not supported by Codex API
14
+ - "verbosity" - Not supported by Codex API
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ from typing import Any
22
+
23
+ import httpx
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def _is_reasoning_model(model_name: str) -> bool:
29
+ """Check if a model supports reasoning parameters."""
30
+ reasoning_models = [
31
+ "gpt-5", # All GPT-5 variants
32
+ "o1", # o1 series
33
+ "o3", # o3 series
34
+ "o4", # o4 series
35
+ ]
36
+ model_lower = model_name.lower()
37
+ return any(model_lower.startswith(prefix) for prefix in reasoning_models)
38
+
39
+
40
+ class ChatGPTCodexAsyncClient(httpx.AsyncClient):
41
+ """Async HTTP client that handles ChatGPT Codex API requirements.
42
+
43
+ This client:
44
+ 1. Injects required fields (store=false, stream=true)
45
+ 2. Strips unsupported parameters
46
+ 3. Converts streaming responses to non-streaming format
47
+ """
48
+
49
+ async def send(
50
+ self, request: httpx.Request, *args: Any, **kwargs: Any
51
+ ) -> httpx.Response:
52
+ """Intercept requests and inject required Codex fields."""
53
+ force_stream_conversion = False
54
+
55
+ try:
56
+ # Only modify POST requests to the Codex API
57
+ if request.method == "POST":
58
+ body_bytes = self._extract_body_bytes(request)
59
+ if body_bytes:
60
+ updated, force_stream_conversion = self._inject_codex_fields(
61
+ body_bytes
62
+ )
63
+ if updated is not None:
64
+ try:
65
+ rebuilt = self.build_request(
66
+ method=request.method,
67
+ url=request.url,
68
+ headers=request.headers,
69
+ content=updated,
70
+ )
71
+
72
+ # Copy core internals so httpx uses the modified body/stream
73
+ if hasattr(rebuilt, "_content"):
74
+ setattr(request, "_content", rebuilt._content)
75
+ if hasattr(rebuilt, "stream"):
76
+ request.stream = rebuilt.stream
77
+ if hasattr(rebuilt, "extensions"):
78
+ request.extensions = rebuilt.extensions
79
+
80
+ # Ensure Content-Length matches the new body
81
+ request.headers["Content-Length"] = str(len(updated))
82
+
83
+ except Exception:
84
+ pass
85
+ except Exception:
86
+ pass
87
+
88
+ # Make the actual request
89
+ response = await super().send(request, *args, **kwargs)
90
+
91
+ # If we forced streaming, convert the SSE stream to a regular response
92
+ if force_stream_conversion and response.status_code == 200:
93
+ try:
94
+ response = await self._convert_stream_to_response(response)
95
+ except Exception as e:
96
+ logger.warning(f"Failed to convert stream response: {e}")
97
+
98
+ return response
99
+
100
+ @staticmethod
101
+ def _extract_body_bytes(request: httpx.Request) -> bytes | None:
102
+ """Extract the request body as bytes."""
103
+ try:
104
+ content = request.content
105
+ if content:
106
+ return content
107
+ except Exception:
108
+ pass
109
+
110
+ try:
111
+ content = getattr(request, "_content", None)
112
+ if content:
113
+ return content
114
+ except Exception:
115
+ pass
116
+
117
+ return None
118
+
119
+ @staticmethod
120
+ def _inject_codex_fields(body: bytes) -> tuple[bytes | None, bool]:
121
+ """Inject required Codex fields and remove unsupported ones.
122
+
123
+ Returns:
124
+ Tuple of (modified body bytes or None, whether stream was forced)
125
+ """
126
+ try:
127
+ data = json.loads(body.decode("utf-8"))
128
+ except Exception:
129
+ return None, False
130
+
131
+ if not isinstance(data, dict):
132
+ return None, False
133
+
134
+ modified = False
135
+ forced_stream = False
136
+
137
+ # CRITICAL: ChatGPT Codex backend requires store=false
138
+ if "store" not in data or data.get("store") is not False:
139
+ data["store"] = False
140
+ modified = True
141
+
142
+ # CRITICAL: ChatGPT Codex backend requires stream=true
143
+ # If stream is already true (e.g., pydantic-ai with event_stream_handler),
144
+ # don't force conversion - let streaming events flow through naturally
145
+ if data.get("stream") is not True:
146
+ data["stream"] = True
147
+ forced_stream = True # Only convert if WE forced streaming
148
+ modified = True
149
+
150
+ # Add reasoning settings for reasoning models (gpt-5.2, o-series, etc.)
151
+ model = data.get("model", "")
152
+ if "reasoning" not in data and _is_reasoning_model(model):
153
+ data["reasoning"] = {
154
+ "effort": "medium",
155
+ "summary": "auto",
156
+ }
157
+ modified = True
158
+
159
+ # Remove unsupported parameters
160
+ # Note: verbosity should be under "text" object, not top-level
161
+ unsupported_params = ["max_output_tokens", "max_tokens", "verbosity"]
162
+ for param in unsupported_params:
163
+ if param in data:
164
+ del data[param]
165
+ modified = True
166
+
167
+ if not modified:
168
+ return None, False
169
+
170
+ return json.dumps(data).encode("utf-8"), forced_stream
171
+
172
+ async def _convert_stream_to_response(
173
+ self, response: httpx.Response
174
+ ) -> httpx.Response:
175
+ """Convert an SSE streaming response to a complete response.
176
+
177
+ Consumes the SSE stream and reconstructs the final response object.
178
+ """
179
+ logger.debug("Converting SSE stream to non-streaming response")
180
+ final_response_data = None
181
+ collected_text = []
182
+ collected_tool_calls = []
183
+
184
+ # Read the entire stream
185
+ async for line in response.aiter_lines():
186
+ if not line or not line.startswith("data:"):
187
+ continue
188
+
189
+ data_str = line[5:].strip() # Remove "data:" prefix
190
+ if data_str == "[DONE]":
191
+ break
192
+
193
+ try:
194
+ event = json.loads(data_str)
195
+ event_type = event.get("type", "")
196
+
197
+ if event_type == "response.output_text.delta":
198
+ # Collect text deltas
199
+ delta = event.get("delta", "")
200
+ if delta:
201
+ collected_text.append(delta)
202
+
203
+ elif event_type == "response.completed":
204
+ # This contains the final response object
205
+ final_response_data = event.get("response", {})
206
+
207
+ elif event_type == "response.function_call_arguments.done":
208
+ # Collect tool calls
209
+ tool_call = {
210
+ "name": event.get("name", ""),
211
+ "arguments": event.get("arguments", ""),
212
+ "call_id": event.get("call_id", ""),
213
+ }
214
+ collected_tool_calls.append(tool_call)
215
+
216
+ except json.JSONDecodeError:
217
+ continue
218
+
219
+ logger.debug(
220
+ f"Collected {len(collected_text)} text chunks, {len(collected_tool_calls)} tool calls"
221
+ )
222
+ if final_response_data:
223
+ logger.debug(
224
+ f"Got final response data with keys: {list(final_response_data.keys())}"
225
+ )
226
+
227
+ # Build the final response body
228
+ if final_response_data:
229
+ response_body = final_response_data
230
+ else:
231
+ # Fallback: construct a minimal response from collected data
232
+ response_body = {
233
+ "id": "reconstructed",
234
+ "object": "response",
235
+ "output": [],
236
+ }
237
+
238
+ if collected_text:
239
+ response_body["output"].append(
240
+ {
241
+ "type": "message",
242
+ "role": "assistant",
243
+ "content": [
244
+ {"type": "output_text", "text": "".join(collected_text)}
245
+ ],
246
+ }
247
+ )
248
+
249
+ for tool_call in collected_tool_calls:
250
+ response_body["output"].append(
251
+ {
252
+ "type": "function_call",
253
+ "name": tool_call["name"],
254
+ "arguments": tool_call["arguments"],
255
+ "call_id": tool_call["call_id"],
256
+ }
257
+ )
258
+
259
+ # Create a new response with the complete body
260
+ body_bytes = json.dumps(response_body).encode("utf-8")
261
+ logger.debug(f"Reconstructed response body: {len(body_bytes)} bytes")
262
+
263
+ new_response = httpx.Response(
264
+ status_code=response.status_code,
265
+ headers=response.headers,
266
+ content=body_bytes,
267
+ request=response.request,
268
+ )
269
+ return new_response
270
+
271
+
272
+ def create_codex_async_client(
273
+ headers: dict[str, str] | None = None,
274
+ verify: str | bool = True,
275
+ **kwargs: Any,
276
+ ) -> ChatGPTCodexAsyncClient:
277
+ """Create a ChatGPT Codex async client with proper configuration."""
278
+ return ChatGPTCodexAsyncClient(
279
+ headers=headers,
280
+ verify=verify,
281
+ timeout=httpx.Timeout(300.0, connect=30.0),
282
+ **kwargs,
283
+ )