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.
- code_puppy/__init__.py +7 -1
- code_puppy/agents/__init__.py +8 -8
- code_puppy/agents/agent_c_reviewer.py +155 -0
- code_puppy/agents/agent_code_puppy.py +9 -2
- code_puppy/agents/agent_code_reviewer.py +90 -0
- code_puppy/agents/agent_cpp_reviewer.py +132 -0
- code_puppy/agents/agent_creator_agent.py +48 -9
- code_puppy/agents/agent_golang_reviewer.py +151 -0
- code_puppy/agents/agent_javascript_reviewer.py +160 -0
- code_puppy/agents/agent_manager.py +146 -199
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_planning.py +163 -0
- code_puppy/agents/agent_python_programmer.py +165 -0
- code_puppy/agents/agent_python_reviewer.py +90 -0
- code_puppy/agents/agent_qa_expert.py +163 -0
- code_puppy/agents/agent_qa_kitten.py +208 -0
- code_puppy/agents/agent_security_auditor.py +181 -0
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/agent_typescript_reviewer.py +166 -0
- code_puppy/agents/base_agent.py +1713 -1
- code_puppy/agents/event_stream_handler.py +350 -0
- code_puppy/agents/json_agent.py +12 -1
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/prompt_reviewer.py +145 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +174 -4
- code_puppy/chatgpt_codex_client.py +283 -0
- code_puppy/claude_cache_client.py +586 -0
- code_puppy/cli_runner.py +916 -0
- code_puppy/command_line/add_model_menu.py +1079 -0
- code_puppy/command_line/agent_menu.py +395 -0
- code_puppy/command_line/attachments.py +395 -0
- code_puppy/command_line/autosave_menu.py +605 -0
- code_puppy/command_line/clipboard.py +527 -0
- code_puppy/command_line/colors_menu.py +520 -0
- code_puppy/command_line/command_handler.py +233 -627
- code_puppy/command_line/command_registry.py +150 -0
- code_puppy/command_line/config_commands.py +715 -0
- code_puppy/command_line/core_commands.py +792 -0
- code_puppy/command_line/diff_menu.py +863 -0
- code_puppy/command_line/load_context_completion.py +15 -22
- code_puppy/command_line/mcp/base.py +1 -4
- code_puppy/command_line/mcp/catalog_server_installer.py +175 -0
- code_puppy/command_line/mcp/custom_server_form.py +688 -0
- code_puppy/command_line/mcp/custom_server_installer.py +195 -0
- code_puppy/command_line/mcp/edit_command.py +148 -0
- code_puppy/command_line/mcp/handler.py +9 -4
- code_puppy/command_line/mcp/help_command.py +6 -5
- code_puppy/command_line/mcp/install_command.py +16 -27
- code_puppy/command_line/mcp/install_menu.py +685 -0
- code_puppy/command_line/mcp/list_command.py +3 -3
- code_puppy/command_line/mcp/logs_command.py +174 -65
- code_puppy/command_line/mcp/remove_command.py +2 -2
- code_puppy/command_line/mcp/restart_command.py +12 -4
- code_puppy/command_line/mcp/search_command.py +17 -11
- code_puppy/command_line/mcp/start_all_command.py +22 -13
- code_puppy/command_line/mcp/start_command.py +50 -31
- code_puppy/command_line/mcp/status_command.py +6 -7
- code_puppy/command_line/mcp/stop_all_command.py +11 -8
- code_puppy/command_line/mcp/stop_command.py +11 -10
- code_puppy/command_line/mcp/test_command.py +2 -2
- code_puppy/command_line/mcp/utils.py +1 -1
- code_puppy/command_line/mcp/wizard_utils.py +22 -18
- code_puppy/command_line/mcp_completion.py +174 -0
- code_puppy/command_line/model_picker_completion.py +89 -30
- code_puppy/command_line/model_settings_menu.py +884 -0
- code_puppy/command_line/motd.py +14 -8
- code_puppy/command_line/onboarding_slides.py +179 -0
- code_puppy/command_line/onboarding_wizard.py +340 -0
- code_puppy/command_line/pin_command_completion.py +329 -0
- code_puppy/command_line/prompt_toolkit_completion.py +626 -75
- code_puppy/command_line/session_commands.py +296 -0
- code_puppy/command_line/utils.py +54 -0
- code_puppy/config.py +1181 -51
- code_puppy/error_logging.py +118 -0
- code_puppy/gemini_code_assist.py +385 -0
- code_puppy/gemini_model.py +602 -0
- code_puppy/http_utils.py +220 -104
- code_puppy/keymap.py +128 -0
- code_puppy/main.py +5 -594
- code_puppy/{mcp → mcp_}/__init__.py +17 -0
- code_puppy/{mcp → mcp_}/async_lifecycle.py +35 -4
- code_puppy/{mcp → mcp_}/blocking_startup.py +70 -43
- code_puppy/{mcp → mcp_}/captured_stdio_server.py +2 -2
- code_puppy/{mcp → mcp_}/config_wizard.py +5 -5
- code_puppy/{mcp → mcp_}/dashboard.py +15 -6
- code_puppy/{mcp → mcp_}/examples/retry_example.py +4 -1
- code_puppy/{mcp → mcp_}/managed_server.py +66 -39
- code_puppy/{mcp → mcp_}/manager.py +146 -52
- code_puppy/mcp_/mcp_logs.py +224 -0
- code_puppy/{mcp → mcp_}/registry.py +6 -6
- code_puppy/{mcp → mcp_}/server_registry_catalog.py +25 -8
- code_puppy/messaging/__init__.py +199 -2
- code_puppy/messaging/bus.py +610 -0
- code_puppy/messaging/commands.py +167 -0
- code_puppy/messaging/markdown_patches.py +57 -0
- code_puppy/messaging/message_queue.py +17 -48
- code_puppy/messaging/messages.py +500 -0
- code_puppy/messaging/queue_console.py +1 -24
- code_puppy/messaging/renderers.py +43 -146
- code_puppy/messaging/rich_renderer.py +1027 -0
- code_puppy/messaging/spinner/__init__.py +33 -5
- code_puppy/messaging/spinner/console_spinner.py +92 -52
- code_puppy/messaging/spinner/spinner_base.py +29 -0
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +686 -80
- code_puppy/model_utils.py +167 -0
- code_puppy/models.json +86 -104
- code_puppy/models_dev_api.json +1 -0
- code_puppy/models_dev_parser.py +592 -0
- code_puppy/plugins/__init__.py +164 -10
- code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
- code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +704 -0
- code_puppy/plugins/antigravity_oauth/config.py +42 -0
- code_puppy/plugins/antigravity_oauth/constants.py +136 -0
- code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
- code_puppy/plugins/antigravity_oauth/storage.py +271 -0
- code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
- code_puppy/plugins/antigravity_oauth/token.py +167 -0
- code_puppy/plugins/antigravity_oauth/transport.py +767 -0
- code_puppy/plugins/antigravity_oauth/utils.py +169 -0
- code_puppy/plugins/chatgpt_oauth/__init__.py +8 -0
- code_puppy/plugins/chatgpt_oauth/config.py +52 -0
- code_puppy/plugins/chatgpt_oauth/oauth_flow.py +328 -0
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +94 -0
- code_puppy/plugins/chatgpt_oauth/test_plugin.py +293 -0
- code_puppy/plugins/chatgpt_oauth/utils.py +489 -0
- code_puppy/plugins/claude_code_oauth/README.md +167 -0
- code_puppy/plugins/claude_code_oauth/SETUP.md +93 -0
- code_puppy/plugins/claude_code_oauth/__init__.py +6 -0
- code_puppy/plugins/claude_code_oauth/config.py +50 -0
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +308 -0
- code_puppy/plugins/claude_code_oauth/test_plugin.py +283 -0
- code_puppy/plugins/claude_code_oauth/utils.py +518 -0
- code_puppy/plugins/customizable_commands/__init__.py +0 -0
- code_puppy/plugins/customizable_commands/register_callbacks.py +169 -0
- code_puppy/plugins/example_custom_command/README.md +280 -0
- code_puppy/plugins/example_custom_command/register_callbacks.py +51 -0
- code_puppy/plugins/file_permission_handler/__init__.py +4 -0
- code_puppy/plugins/file_permission_handler/register_callbacks.py +523 -0
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/plugins/oauth_puppy_html.py +228 -0
- code_puppy/plugins/shell_safety/__init__.py +6 -0
- code_puppy/plugins/shell_safety/agent_shell_safety.py +69 -0
- code_puppy/plugins/shell_safety/command_cache.py +156 -0
- code_puppy/plugins/shell_safety/register_callbacks.py +202 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/prompts/codex_system_prompt.md +310 -0
- code_puppy/pydantic_patches.py +131 -0
- code_puppy/reopenable_async_client.py +8 -8
- code_puppy/round_robin_model.py +10 -15
- code_puppy/session_storage.py +294 -0
- code_puppy/status_display.py +21 -4
- code_puppy/summarization_agent.py +52 -14
- code_puppy/terminal_utils.py +418 -0
- code_puppy/tools/__init__.py +139 -6
- code_puppy/tools/agent_tools.py +548 -49
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +289 -0
- code_puppy/tools/browser/browser_interactions.py +545 -0
- code_puppy/tools/browser/browser_locators.py +640 -0
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +251 -0
- code_puppy/tools/browser/browser_screenshot.py +179 -0
- code_puppy/tools/browser/browser_scripts.py +462 -0
- code_puppy/tools/browser/browser_workflows.py +221 -0
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +941 -153
- code_puppy/tools/common.py +1146 -6
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/file_modifications.py +288 -89
- code_puppy/tools/file_operations.py +352 -266
- code_puppy/tools/subagent_context.py +158 -0
- code_puppy/uvx_detection.py +242 -0
- code_puppy/version_checker.py +30 -11
- code_puppy-0.0.366.data/data/code_puppy/models.json +110 -0
- code_puppy-0.0.366.data/data/code_puppy/models_dev_api.json +1 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/METADATA +184 -67
- code_puppy-0.0.366.dist-info/RECORD +217 -0
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/WHEEL +1 -1
- {code_puppy-0.0.169.dist-info → code_puppy-0.0.366.dist-info}/entry_points.txt +1 -0
- code_puppy/agent.py +0 -231
- code_puppy/agents/agent_orchestrator.json +0 -26
- code_puppy/agents/runtime_manager.py +0 -272
- code_puppy/command_line/mcp/add_command.py +0 -183
- code_puppy/command_line/meta_command_handler.py +0 -153
- code_puppy/message_history_processor.py +0 -490
- code_puppy/messaging/spinner/textual_spinner.py +0 -101
- code_puppy/state_management.py +0 -200
- code_puppy/tui/__init__.py +0 -10
- code_puppy/tui/app.py +0 -986
- code_puppy/tui/components/__init__.py +0 -21
- code_puppy/tui/components/chat_view.py +0 -550
- code_puppy/tui/components/command_history_modal.py +0 -218
- code_puppy/tui/components/copy_button.py +0 -139
- code_puppy/tui/components/custom_widgets.py +0 -63
- code_puppy/tui/components/human_input_modal.py +0 -175
- code_puppy/tui/components/input_area.py +0 -167
- code_puppy/tui/components/sidebar.py +0 -309
- code_puppy/tui/components/status_bar.py +0 -182
- code_puppy/tui/messages.py +0 -27
- code_puppy/tui/models/__init__.py +0 -8
- code_puppy/tui/models/chat_message.py +0 -25
- code_puppy/tui/models/command_history.py +0 -89
- code_puppy/tui/models/enums.py +0 -24
- code_puppy/tui/screens/__init__.py +0 -15
- code_puppy/tui/screens/help.py +0 -130
- code_puppy/tui/screens/mcp_install_wizard.py +0 -803
- code_puppy/tui/screens/settings.py +0 -290
- code_puppy/tui/screens/tools.py +0 -74
- code_puppy-0.0.169.data/data/code_puppy/models.json +0 -128
- code_puppy-0.0.169.dist-info/RECORD +0 -112
- /code_puppy/{mcp → mcp_}/circuit_breaker.py +0 -0
- /code_puppy/{mcp → mcp_}/error_isolation.py +0 -0
- /code_puppy/{mcp → mcp_}/health_monitor.py +0 -0
- /code_puppy/{mcp → mcp_}/retry_manager.py +0 -0
- /code_puppy/{mcp → mcp_}/status_tracker.py +0 -0
- /code_puppy/{mcp → mcp_}/system_tools.py +0 -0
- {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
|
|
133
|
+
logger.debug(f"Successfully executed callback {callback.__name__}")
|
|
98
134
|
except Exception as e:
|
|
99
135
|
logger.error(
|
|
100
|
-
f"
|
|
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
|
|
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
|
+
)
|