code-puppy 0.0.214__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 (231) hide show
  1. code_puppy/__init__.py +7 -1
  2. code_puppy/agents/__init__.py +2 -0
  3. code_puppy/agents/agent_c_reviewer.py +59 -6
  4. code_puppy/agents/agent_code_puppy.py +7 -1
  5. code_puppy/agents/agent_code_reviewer.py +12 -2
  6. code_puppy/agents/agent_cpp_reviewer.py +73 -6
  7. code_puppy/agents/agent_creator_agent.py +45 -4
  8. code_puppy/agents/agent_golang_reviewer.py +92 -3
  9. code_puppy/agents/agent_javascript_reviewer.py +101 -8
  10. code_puppy/agents/agent_manager.py +81 -4
  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 +28 -6
  15. code_puppy/agents/agent_qa_expert.py +98 -6
  16. code_puppy/agents/agent_qa_kitten.py +12 -7
  17. code_puppy/agents/agent_security_auditor.py +113 -3
  18. code_puppy/agents/agent_terminal_qa.py +323 -0
  19. code_puppy/agents/agent_typescript_reviewer.py +106 -7
  20. code_puppy/agents/base_agent.py +802 -176
  21. code_puppy/agents/event_stream_handler.py +350 -0
  22. code_puppy/agents/pack/__init__.py +34 -0
  23. code_puppy/agents/pack/bloodhound.py +304 -0
  24. code_puppy/agents/pack/husky.py +321 -0
  25. code_puppy/agents/pack/retriever.py +393 -0
  26. code_puppy/agents/pack/shepherd.py +348 -0
  27. code_puppy/agents/pack/terrier.py +287 -0
  28. code_puppy/agents/pack/watchdog.py +367 -0
  29. code_puppy/agents/prompt_reviewer.py +145 -0
  30. code_puppy/agents/subagent_stream_handler.py +276 -0
  31. code_puppy/api/__init__.py +13 -0
  32. code_puppy/api/app.py +169 -0
  33. code_puppy/api/main.py +21 -0
  34. code_puppy/api/pty_manager.py +446 -0
  35. code_puppy/api/routers/__init__.py +12 -0
  36. code_puppy/api/routers/agents.py +36 -0
  37. code_puppy/api/routers/commands.py +217 -0
  38. code_puppy/api/routers/config.py +74 -0
  39. code_puppy/api/routers/sessions.py +232 -0
  40. code_puppy/api/templates/terminal.html +361 -0
  41. code_puppy/api/websocket.py +154 -0
  42. code_puppy/callbacks.py +142 -4
  43. code_puppy/chatgpt_codex_client.py +283 -0
  44. code_puppy/claude_cache_client.py +586 -0
  45. code_puppy/cli_runner.py +916 -0
  46. code_puppy/command_line/add_model_menu.py +1079 -0
  47. code_puppy/command_line/agent_menu.py +395 -0
  48. code_puppy/command_line/attachments.py +10 -5
  49. code_puppy/command_line/autosave_menu.py +605 -0
  50. code_puppy/command_line/clipboard.py +527 -0
  51. code_puppy/command_line/colors_menu.py +520 -0
  52. code_puppy/command_line/command_handler.py +176 -738
  53. code_puppy/command_line/command_registry.py +150 -0
  54. code_puppy/command_line/config_commands.py +715 -0
  55. code_puppy/command_line/core_commands.py +792 -0
  56. code_puppy/command_line/diff_menu.py +863 -0
  57. code_puppy/command_line/load_context_completion.py +15 -22
  58. code_puppy/command_line/mcp/base.py +0 -3
  59. code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
  60. code_puppy/command_line/mcp/custom_server_form.py +688 -0
  61. code_puppy/command_line/mcp/custom_server_installer.py +195 -0
  62. code_puppy/command_line/mcp/edit_command.py +148 -0
  63. code_puppy/command_line/mcp/handler.py +9 -4
  64. code_puppy/command_line/mcp/help_command.py +6 -5
  65. code_puppy/command_line/mcp/install_command.py +15 -26
  66. code_puppy/command_line/mcp/install_menu.py +685 -0
  67. code_puppy/command_line/mcp/list_command.py +2 -2
  68. code_puppy/command_line/mcp/logs_command.py +174 -65
  69. code_puppy/command_line/mcp/remove_command.py +2 -2
  70. code_puppy/command_line/mcp/restart_command.py +12 -4
  71. code_puppy/command_line/mcp/search_command.py +16 -10
  72. code_puppy/command_line/mcp/start_all_command.py +18 -6
  73. code_puppy/command_line/mcp/start_command.py +47 -25
  74. code_puppy/command_line/mcp/status_command.py +4 -5
  75. code_puppy/command_line/mcp/stop_all_command.py +7 -1
  76. code_puppy/command_line/mcp/stop_command.py +8 -4
  77. code_puppy/command_line/mcp/test_command.py +2 -2
  78. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  79. code_puppy/command_line/mcp_completion.py +174 -0
  80. code_puppy/command_line/model_picker_completion.py +75 -25
  81. code_puppy/command_line/model_settings_menu.py +884 -0
  82. code_puppy/command_line/motd.py +14 -8
  83. code_puppy/command_line/onboarding_slides.py +179 -0
  84. code_puppy/command_line/onboarding_wizard.py +340 -0
  85. code_puppy/command_line/pin_command_completion.py +329 -0
  86. code_puppy/command_line/prompt_toolkit_completion.py +463 -63
  87. code_puppy/command_line/session_commands.py +296 -0
  88. code_puppy/command_line/utils.py +54 -0
  89. code_puppy/config.py +898 -112
  90. code_puppy/error_logging.py +118 -0
  91. code_puppy/gemini_code_assist.py +385 -0
  92. code_puppy/gemini_model.py +602 -0
  93. code_puppy/http_utils.py +210 -148
  94. code_puppy/keymap.py +128 -0
  95. code_puppy/main.py +5 -698
  96. code_puppy/mcp_/__init__.py +17 -0
  97. code_puppy/mcp_/async_lifecycle.py +35 -4
  98. code_puppy/mcp_/blocking_startup.py +70 -43
  99. code_puppy/mcp_/captured_stdio_server.py +2 -2
  100. code_puppy/mcp_/config_wizard.py +4 -4
  101. code_puppy/mcp_/dashboard.py +15 -6
  102. code_puppy/mcp_/managed_server.py +65 -38
  103. code_puppy/mcp_/manager.py +146 -52
  104. code_puppy/mcp_/mcp_logs.py +224 -0
  105. code_puppy/mcp_/registry.py +6 -6
  106. code_puppy/mcp_/server_registry_catalog.py +24 -5
  107. code_puppy/messaging/__init__.py +199 -2
  108. code_puppy/messaging/bus.py +610 -0
  109. code_puppy/messaging/commands.py +167 -0
  110. code_puppy/messaging/markdown_patches.py +57 -0
  111. code_puppy/messaging/message_queue.py +17 -48
  112. code_puppy/messaging/messages.py +500 -0
  113. code_puppy/messaging/queue_console.py +1 -24
  114. code_puppy/messaging/renderers.py +43 -146
  115. code_puppy/messaging/rich_renderer.py +1027 -0
  116. code_puppy/messaging/spinner/__init__.py +21 -5
  117. code_puppy/messaging/spinner/console_spinner.py +86 -51
  118. code_puppy/messaging/subagent_console.py +461 -0
  119. code_puppy/model_factory.py +634 -83
  120. code_puppy/model_utils.py +167 -0
  121. code_puppy/models.json +66 -68
  122. code_puppy/models_dev_api.json +1 -0
  123. code_puppy/models_dev_parser.py +592 -0
  124. code_puppy/plugins/__init__.py +164 -10
  125. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  126. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  127. code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
  128. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  129. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  130. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  131. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  132. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  133. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  134. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  135. code_puppy/plugins/antigravity_oauth/transport.py +767 -0
  136. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  137. code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
  138. code_puppy/plugins/chatgpt_oauth/config.py +52 -0
  139. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
  140. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
  141. code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
  142. code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
  143. code_puppy/plugins/claude_code_oauth/README.md +167 -0
  144. code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
  145. code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
  146. code_puppy/plugins/claude_code_oauth/config.py +50 -0
  147. code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
  148. code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
  149. code_puppy/plugins/claude_code_oauth/utils.py +518 -0
  150. code_puppy/plugins/customizable_commands/__init__.py +0 -0
  151. code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
  152. code_puppy/plugins/example_custom_command/README.md +280 -0
  153. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  154. code_puppy/plugins/file_permission_handler/__init__.py +4 -0
  155. code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
  156. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  157. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  158. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  159. code_puppy/plugins/oauth_puppy_html.py +228 -0
  160. code_puppy/plugins/shell_safety/__init__.py +6 -0
  161. code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
  162. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  163. code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
  164. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  165. code_puppy/prompts/codex_system_prompt.md +310 -0
  166. code_puppy/pydantic_patches.py +131 -0
  167. code_puppy/reopenable_async_client.py +8 -8
  168. code_puppy/round_robin_model.py +9 -12
  169. code_puppy/session_storage.py +2 -1
  170. code_puppy/status_display.py +21 -4
  171. code_puppy/summarization_agent.py +41 -13
  172. code_puppy/terminal_utils.py +418 -0
  173. code_puppy/tools/__init__.py +37 -1
  174. code_puppy/tools/agent_tools.py +536 -52
  175. code_puppy/tools/browser/__init__.py +37 -0
  176. code_puppy/tools/browser/browser_control.py +19 -23
  177. code_puppy/tools/browser/browser_interactions.py +41 -48
  178. code_puppy/tools/browser/browser_locators.py +36 -38
  179. code_puppy/tools/browser/browser_manager.py +316 -0
  180. code_puppy/tools/browser/browser_navigation.py +16 -16
  181. code_puppy/tools/browser/browser_screenshot.py +79 -143
  182. code_puppy/tools/browser/browser_scripts.py +32 -42
  183. code_puppy/tools/browser/browser_workflows.py +44 -27
  184. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  185. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  186. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  187. code_puppy/tools/browser/terminal_tools.py +525 -0
  188. code_puppy/tools/command_runner.py +930 -147
  189. code_puppy/tools/common.py +1113 -5
  190. code_puppy/tools/display.py +84 -0
  191. code_puppy/tools/file_modifications.py +288 -89
  192. code_puppy/tools/file_operations.py +226 -154
  193. code_puppy/tools/subagent_context.py +158 -0
  194. code_puppy/uvx_detection.py +242 -0
  195. code_puppy/version_checker.py +30 -11
  196. code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
  197. code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
  198. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/METADATA +149 -75
  199. code_puppy-0.0.366.dist-info/RECORD +217 -0
  200. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
  201. code_puppy/command_line/mcp/add_command.py +0 -183
  202. code_puppy/messaging/spinner/textual_spinner.py +0 -106
  203. code_puppy/tools/browser/camoufox_manager.py +0 -216
  204. code_puppy/tools/browser/vqa_agent.py +0 -70
  205. code_puppy/tui/__init__.py +0 -10
  206. code_puppy/tui/app.py +0 -1105
  207. code_puppy/tui/components/__init__.py +0 -21
  208. code_puppy/tui/components/chat_view.py +0 -551
  209. code_puppy/tui/components/command_history_modal.py +0 -218
  210. code_puppy/tui/components/copy_button.py +0 -139
  211. code_puppy/tui/components/custom_widgets.py +0 -63
  212. code_puppy/tui/components/human_input_modal.py +0 -175
  213. code_puppy/tui/components/input_area.py +0 -167
  214. code_puppy/tui/components/sidebar.py +0 -309
  215. code_puppy/tui/components/status_bar.py +0 -185
  216. code_puppy/tui/messages.py +0 -27
  217. code_puppy/tui/models/__init__.py +0 -8
  218. code_puppy/tui/models/chat_message.py +0 -25
  219. code_puppy/tui/models/command_history.py +0 -89
  220. code_puppy/tui/models/enums.py +0 -24
  221. code_puppy/tui/screens/__init__.py +0 -17
  222. code_puppy/tui/screens/autosave_picker.py +0 -175
  223. code_puppy/tui/screens/help.py +0 -130
  224. code_puppy/tui/screens/mcp_install_wizard.py +0 -803
  225. code_puppy/tui/screens/settings.py +0 -306
  226. code_puppy/tui/screens/tools.py +0 -74
  227. code_puppy/tui_state.py +0 -55
  228. code_puppy-0.0.214.data/data/code_puppy/models.json +0 -112
  229. code_puppy-0.0.214.dist-info/RECORD +0 -131
  230. {code_puppy-0.0.214.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +0 -0
  231. {code_puppy-0.0.214.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
@@ -17,6 +17,10 @@ PhaseType = Literal[
17
17
  "agent_reload",
18
18
  "custom_command",
19
19
  "custom_command_help",
20
+ "file_permission",
21
+ "pre_tool_call",
22
+ "post_tool_call",
23
+ "stream_event",
20
24
  ]
21
25
  CallbackFunc = Callable[..., Any]
22
26
 
@@ -34,6 +38,10 @@ _callbacks: Dict[PhaseType, List[CallbackFunc]] = {
34
38
  "agent_reload": [],
35
39
  "custom_command": [],
36
40
  "custom_command_help": [],
41
+ "file_permission": [],
42
+ "pre_tool_call": [],
43
+ "post_tool_call": [],
44
+ "stream_event": [],
37
45
  }
38
46
 
39
47
  logger = logging.getLogger(__name__)
@@ -48,6 +56,14 @@ def register_callback(phase: PhaseType, func: CallbackFunc) -> None:
48
56
  if not callable(func):
49
57
  raise TypeError(f"Callback must be callable, got {type(func)}")
50
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
+
51
67
  _callbacks[phase].append(func)
52
68
  logger.debug(f"Registered async callback {func.__name__} for phase '{phase}'")
53
69
 
@@ -97,11 +113,27 @@ def _trigger_callbacks_sync(phase: PhaseType, *args, **kwargs) -> List[Any]:
97
113
  for callback in callbacks:
98
114
  try:
99
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)
100
132
  results.append(result)
101
- logger.debug(f"Successfully executed async callback {callback.__name__}")
133
+ logger.debug(f"Successfully executed callback {callback.__name__}")
102
134
  except Exception as e:
103
135
  logger.error(
104
- f"Async callback {callback.__name__} failed in phase '{phase}': {e}\n"
136
+ f"Callback {callback.__name__} failed in phase '{phase}': {e}\n"
105
137
  f"{traceback.format_exc()}"
106
138
  )
107
139
  results.append(None)
@@ -168,8 +200,8 @@ def on_delete_file(*args, **kwargs) -> Any:
168
200
  return _trigger_callbacks_sync("delete_file", *args, **kwargs)
169
201
 
170
202
 
171
- def on_run_shell_command(*args, **kwargs) -> Any:
172
- 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)
173
205
 
174
206
 
175
207
  def on_agent_reload(*args, **kwargs) -> Any:
@@ -206,3 +238,109 @@ def on_custom_command(command: str, name: str) -> List[Any]:
206
238
  - None to indicate not handled
207
239
  """
208
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
+ )