code-puppy 0.0.348__py3-none-any.whl → 0.0.372__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 (87) hide show
  1. code_puppy/agents/__init__.py +8 -0
  2. code_puppy/agents/agent_manager.py +272 -1
  3. code_puppy/agents/agent_pack_leader.py +383 -0
  4. code_puppy/agents/agent_qa_kitten.py +12 -7
  5. code_puppy/agents/agent_terminal_qa.py +323 -0
  6. code_puppy/agents/base_agent.py +11 -8
  7. code_puppy/agents/event_stream_handler.py +101 -8
  8. code_puppy/agents/pack/__init__.py +34 -0
  9. code_puppy/agents/pack/bloodhound.py +304 -0
  10. code_puppy/agents/pack/husky.py +321 -0
  11. code_puppy/agents/pack/retriever.py +393 -0
  12. code_puppy/agents/pack/shepherd.py +348 -0
  13. code_puppy/agents/pack/terrier.py +287 -0
  14. code_puppy/agents/pack/watchdog.py +367 -0
  15. code_puppy/agents/subagent_stream_handler.py +276 -0
  16. code_puppy/api/__init__.py +13 -0
  17. code_puppy/api/app.py +169 -0
  18. code_puppy/api/main.py +21 -0
  19. code_puppy/api/pty_manager.py +446 -0
  20. code_puppy/api/routers/__init__.py +12 -0
  21. code_puppy/api/routers/agents.py +36 -0
  22. code_puppy/api/routers/commands.py +217 -0
  23. code_puppy/api/routers/config.py +74 -0
  24. code_puppy/api/routers/sessions.py +232 -0
  25. code_puppy/api/templates/terminal.html +361 -0
  26. code_puppy/api/websocket.py +154 -0
  27. code_puppy/callbacks.py +73 -0
  28. code_puppy/chatgpt_codex_client.py +53 -0
  29. code_puppy/claude_cache_client.py +294 -41
  30. code_puppy/command_line/add_model_menu.py +13 -4
  31. code_puppy/command_line/agent_menu.py +662 -0
  32. code_puppy/command_line/core_commands.py +89 -112
  33. code_puppy/command_line/model_picker_completion.py +3 -20
  34. code_puppy/command_line/model_settings_menu.py +21 -3
  35. code_puppy/config.py +145 -70
  36. code_puppy/gemini_model.py +706 -0
  37. code_puppy/http_utils.py +6 -3
  38. code_puppy/messaging/__init__.py +15 -0
  39. code_puppy/messaging/messages.py +27 -0
  40. code_puppy/messaging/queue_console.py +1 -1
  41. code_puppy/messaging/rich_renderer.py +36 -1
  42. code_puppy/messaging/spinner/__init__.py +20 -2
  43. code_puppy/messaging/subagent_console.py +461 -0
  44. code_puppy/model_factory.py +50 -16
  45. code_puppy/model_switching.py +63 -0
  46. code_puppy/model_utils.py +27 -24
  47. code_puppy/models.json +12 -12
  48. code_puppy/plugins/antigravity_oauth/antigravity_model.py +206 -172
  49. code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
  50. code_puppy/plugins/antigravity_oauth/transport.py +236 -45
  51. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
  52. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
  53. code_puppy/plugins/claude_code_oauth/utils.py +4 -1
  54. code_puppy/plugins/frontend_emitter/__init__.py +25 -0
  55. code_puppy/plugins/frontend_emitter/emitter.py +121 -0
  56. code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
  57. code_puppy/prompts/antigravity_system_prompt.md +1 -0
  58. code_puppy/pydantic_patches.py +52 -0
  59. code_puppy/status_display.py +6 -2
  60. code_puppy/tools/__init__.py +37 -1
  61. code_puppy/tools/agent_tools.py +83 -33
  62. code_puppy/tools/browser/__init__.py +37 -0
  63. code_puppy/tools/browser/browser_control.py +6 -6
  64. code_puppy/tools/browser/browser_interactions.py +21 -20
  65. code_puppy/tools/browser/browser_locators.py +9 -9
  66. code_puppy/tools/browser/browser_manager.py +316 -0
  67. code_puppy/tools/browser/browser_navigation.py +7 -7
  68. code_puppy/tools/browser/browser_screenshot.py +78 -140
  69. code_puppy/tools/browser/browser_scripts.py +15 -13
  70. code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
  71. code_puppy/tools/browser/terminal_command_tools.py +521 -0
  72. code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
  73. code_puppy/tools/browser/terminal_tools.py +525 -0
  74. code_puppy/tools/command_runner.py +292 -101
  75. code_puppy/tools/common.py +176 -1
  76. code_puppy/tools/display.py +84 -0
  77. code_puppy/tools/subagent_context.py +158 -0
  78. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
  79. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/METADATA +17 -16
  80. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/RECORD +84 -51
  81. code_puppy/prompts/codex_system_prompt.md +0 -310
  82. code_puppy/tools/browser/camoufox_manager.py +0 -235
  83. code_puppy/tools/browser/vqa_agent.py +0 -90
  84. {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
  85. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
  86. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
  87. {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,361 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="h-full">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>🐶 Code Puppy Terminal</title>
7
+
8
+ <!-- Tailwind CSS -->
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+
11
+ <!-- xterm.js CSS -->
12
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" />
13
+
14
+ <!-- xterm.js -->
15
+ <script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
16
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
17
+ <script src="https://cdn.jsdelivr.net/npm/xterm-addon-web-links@0.9.0/lib/xterm-addon-web-links.min.js"></script>
18
+
19
+ <style>
20
+ /* Custom terminal styling */
21
+ .xterm {
22
+ padding: 8px;
23
+ }
24
+
25
+ .xterm-viewport::-webkit-scrollbar {
26
+ width: 8px;
27
+ }
28
+
29
+ .xterm-viewport::-webkit-scrollbar-track {
30
+ background: #1e1e1e;
31
+ }
32
+
33
+ .xterm-viewport::-webkit-scrollbar-thumb {
34
+ background: #4a4a4a;
35
+ border-radius: 4px;
36
+ }
37
+
38
+ .xterm-viewport::-webkit-scrollbar-thumb:hover {
39
+ background: #5a5a5a;
40
+ }
41
+
42
+ .status-indicator {
43
+ width: 10px;
44
+ height: 10px;
45
+ border-radius: 50%;
46
+ animation: pulse 2s infinite;
47
+ }
48
+
49
+ .status-connected {
50
+ background-color: #22c55e;
51
+ box-shadow: 0 0 8px #22c55e;
52
+ }
53
+
54
+ .status-disconnected {
55
+ background-color: #ef4444;
56
+ box-shadow: 0 0 8px #ef4444;
57
+ animation: none;
58
+ }
59
+
60
+ .status-connecting {
61
+ background-color: #f59e0b;
62
+ box-shadow: 0 0 8px #f59e0b;
63
+ }
64
+
65
+ @keyframes pulse {
66
+ 0%, 100% { opacity: 1; }
67
+ 50% { opacity: 0.5; }
68
+ }
69
+ </style>
70
+ </head>
71
+ <body class="h-full bg-gray-900 text-white overflow-hidden">
72
+ <div class="h-full flex flex-col">
73
+ <!-- Header -->
74
+ <header class="bg-gray-800 border-b border-gray-700 px-4 py-2 flex items-center justify-between">
75
+ <div class="flex items-center space-x-3">
76
+ <span class="text-2xl">🐶</span>
77
+ <h1 class="text-lg font-semibold text-gray-100">Code Puppy Terminal</h1>
78
+ </div>
79
+
80
+ <div class="flex items-center space-x-4">
81
+ <!-- Connection Status -->
82
+ <div class="flex items-center space-x-2">
83
+ <div id="status-indicator" class="status-indicator status-disconnected"></div>
84
+ <span id="status-text" class="text-sm text-gray-400">Disconnected</span>
85
+ </div>
86
+
87
+ <!-- Session ID -->
88
+ <div class="text-sm text-gray-500">
89
+ Session: <span id="session-id" class="font-mono text-gray-400">-</span>
90
+ </div>
91
+
92
+ <!-- Controls -->
93
+ <div class="flex items-center space-x-2">
94
+ <button id="btn-reconnect"
95
+ class="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
96
+ disabled>
97
+ Reconnect
98
+ </button>
99
+ <button id="btn-clear"
100
+ class="px-3 py-1 text-sm bg-gray-600 hover:bg-gray-700 rounded transition-colors">
101
+ Clear
102
+ </button>
103
+ </div>
104
+ </div>
105
+ </header>
106
+
107
+ <!-- Terminal Container -->
108
+ <main class="flex-1 bg-black p-2">
109
+ <div id="terminal" class="h-full w-full"></div>
110
+ </main>
111
+
112
+ <!-- Footer -->
113
+ <footer class="bg-gray-800 border-t border-gray-700 px-4 py-1 flex items-center justify-between text-xs text-gray-500">
114
+ <div>
115
+ <span id="terminal-size">80x24</span>
116
+ </div>
117
+ <div class="flex items-center space-x-4">
118
+ <span>Ctrl+C to interrupt</span>
119
+ <span>•</span>
120
+ <span>Ctrl+D to exit</span>
121
+ </div>
122
+ </footer>
123
+ </div>
124
+
125
+ <script>
126
+ // Terminal configuration
127
+ const CONFIG = {
128
+ wsUrl: `ws://${window.location.host}/ws/terminal`,
129
+ sessionId: new URLSearchParams(window.location.search).get('session') || 'default',
130
+ theme: {
131
+ background: '#1e1e1e',
132
+ foreground: '#d4d4d4',
133
+ cursor: '#aeafad',
134
+ cursorAccent: '#1e1e1e',
135
+ selection: 'rgba(255, 255, 255, 0.3)',
136
+ black: '#000000',
137
+ red: '#cd3131',
138
+ green: '#0dbc79',
139
+ yellow: '#e5e510',
140
+ blue: '#2472c8',
141
+ magenta: '#bc3fbc',
142
+ cyan: '#11a8cd',
143
+ white: '#e5e5e5',
144
+ brightBlack: '#666666',
145
+ brightRed: '#f14c4c',
146
+ brightGreen: '#23d18b',
147
+ brightYellow: '#f5f543',
148
+ brightBlue: '#3b8eea',
149
+ brightMagenta: '#d670d6',
150
+ brightCyan: '#29b8db',
151
+ brightWhite: '#ffffff'
152
+ }
153
+ };
154
+
155
+ // State
156
+ let terminal = null;
157
+ let fitAddon = null;
158
+ let socket = null;
159
+ let reconnectAttempts = 0;
160
+ const MAX_RECONNECT_ATTEMPTS = 5;
161
+
162
+ // DOM Elements
163
+ const statusIndicator = document.getElementById('status-indicator');
164
+ const statusText = document.getElementById('status-text');
165
+ const sessionIdEl = document.getElementById('session-id');
166
+ const terminalSizeEl = document.getElementById('terminal-size');
167
+ const btnReconnect = document.getElementById('btn-reconnect');
168
+ const btnClear = document.getElementById('btn-clear');
169
+
170
+ // Initialize terminal
171
+ function initTerminal() {
172
+ terminal = new Terminal({
173
+ theme: CONFIG.theme,
174
+ fontFamily: '"Cascadia Code", "Fira Code", "JetBrains Mono", Menlo, Monaco, monospace',
175
+ fontSize: 14,
176
+ lineHeight: 1.2,
177
+ cursorBlink: true,
178
+ cursorStyle: 'block',
179
+ scrollback: 10000,
180
+ convertEol: true,
181
+ allowTransparency: true
182
+ });
183
+
184
+ // Load addons
185
+ fitAddon = new FitAddon.FitAddon();
186
+ const webLinksAddon = new WebLinksAddon.WebLinksAddon();
187
+
188
+ terminal.loadAddon(fitAddon);
189
+ terminal.loadAddon(webLinksAddon);
190
+
191
+ // Open terminal in container
192
+ const container = document.getElementById('terminal');
193
+ terminal.open(container);
194
+
195
+ // Fit to container
196
+ fitAddon.fit();
197
+ updateTerminalSize();
198
+
199
+ // Handle user input
200
+ terminal.onData(data => {
201
+ if (socket && socket.readyState === WebSocket.OPEN) {
202
+ socket.send(JSON.stringify({
203
+ type: 'input',
204
+ data: data
205
+ }));
206
+ }
207
+ });
208
+
209
+ // Handle resize
210
+ terminal.onResize(({ cols, rows }) => {
211
+ updateTerminalSize();
212
+ if (socket && socket.readyState === WebSocket.OPEN) {
213
+ socket.send(JSON.stringify({
214
+ type: 'resize',
215
+ cols: cols,
216
+ rows: rows
217
+ }));
218
+ }
219
+ });
220
+
221
+ // Welcome message
222
+ terminal.writeln('\x1b[1;36m🐶 Welcome to Code Puppy Terminal!\x1b[0m');
223
+ terminal.writeln('\x1b[90mConnecting to server...\x1b[0m');
224
+ terminal.writeln('');
225
+
226
+ // Connect to WebSocket
227
+ connect();
228
+
229
+ // Update session ID display
230
+ sessionIdEl.textContent = CONFIG.sessionId;
231
+ }
232
+
233
+ // Update terminal size display
234
+ function updateTerminalSize() {
235
+ if (terminal) {
236
+ terminalSizeEl.textContent = `${terminal.cols}x${terminal.rows}`;
237
+ }
238
+ }
239
+
240
+ // Update connection status
241
+ function setStatus(status) {
242
+ statusIndicator.className = 'status-indicator';
243
+
244
+ switch (status) {
245
+ case 'connected':
246
+ statusIndicator.classList.add('status-connected');
247
+ statusText.textContent = 'Connected';
248
+ btnReconnect.disabled = true;
249
+ break;
250
+ case 'disconnected':
251
+ statusIndicator.classList.add('status-disconnected');
252
+ statusText.textContent = 'Disconnected';
253
+ btnReconnect.disabled = false;
254
+ break;
255
+ case 'connecting':
256
+ statusIndicator.classList.add('status-connecting');
257
+ statusText.textContent = 'Connecting...';
258
+ btnReconnect.disabled = true;
259
+ break;
260
+ }
261
+ }
262
+
263
+ // Connect to WebSocket
264
+ function connect() {
265
+ if (socket && socket.readyState === WebSocket.OPEN) {
266
+ return;
267
+ }
268
+
269
+ setStatus('connecting');
270
+
271
+ const url = CONFIG.wsUrl;
272
+ socket = new WebSocket(url);
273
+
274
+ socket.onopen = () => {
275
+ setStatus('connected');
276
+ reconnectAttempts = 0;
277
+ terminal.writeln('\x1b[1;32m✓ Connected to server\x1b[0m');
278
+ terminal.writeln('');
279
+
280
+ // Send initial resize
281
+ socket.send(JSON.stringify({
282
+ type: 'resize',
283
+ cols: terminal.cols,
284
+ rows: terminal.rows
285
+ }));
286
+ };
287
+
288
+ socket.onmessage = (event) => {
289
+ try {
290
+ const message = JSON.parse(event.data);
291
+
292
+ switch (message.type) {
293
+ case 'output':
294
+ terminal.write(new TextDecoder().decode(Uint8Array.from(atob(message.data), c => c.charCodeAt(0))));
295
+ break;
296
+ case 'error':
297
+ terminal.writeln(`\x1b[1;31mError: ${message.message}\x1b[0m`);
298
+ break;
299
+ case 'session_started':
300
+ terminal.writeln(`\x1b[90mSession started: ${message.session_id}\x1b[0m`);
301
+ break;
302
+ }
303
+ } catch (e) {
304
+ // Plain text output
305
+ terminal.write(event.data);
306
+ }
307
+ };
308
+
309
+ socket.onclose = (event) => {
310
+ setStatus('disconnected');
311
+
312
+ if (!event.wasClean) {
313
+ terminal.writeln('');
314
+ terminal.writeln('\x1b[1;31m✗ Connection lost\x1b[0m');
315
+
316
+ // Auto-reconnect with backoff
317
+ if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
318
+ reconnectAttempts++;
319
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
320
+ terminal.writeln(`\x1b[90mReconnecting in ${delay/1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...\x1b[0m`);
321
+ setTimeout(connect, delay);
322
+ } else {
323
+ terminal.writeln('\x1b[90mMax reconnection attempts reached. Click "Reconnect" to try again.\x1b[0m');
324
+ }
325
+ }
326
+ };
327
+
328
+ socket.onerror = (error) => {
329
+ console.error('WebSocket error:', error);
330
+ };
331
+ }
332
+
333
+ // Event listeners
334
+ btnReconnect.addEventListener('click', () => {
335
+ reconnectAttempts = 0;
336
+ connect();
337
+ });
338
+
339
+ btnClear.addEventListener('click', () => {
340
+ terminal.clear();
341
+ });
342
+
343
+ // Handle window resize
344
+ window.addEventListener('resize', () => {
345
+ if (fitAddon) {
346
+ fitAddon.fit();
347
+ }
348
+ });
349
+
350
+ // Handle page unload
351
+ window.addEventListener('beforeunload', () => {
352
+ if (socket) {
353
+ socket.close();
354
+ }
355
+ });
356
+
357
+ // Initialize on DOM ready
358
+ document.addEventListener('DOMContentLoaded', initTerminal);
359
+ </script>
360
+ </body>
361
+ </html>
@@ -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
@@ -18,6 +18,9 @@ PhaseType = Literal[
18
18
  "custom_command",
19
19
  "custom_command_help",
20
20
  "file_permission",
21
+ "pre_tool_call",
22
+ "post_tool_call",
23
+ "stream_event",
21
24
  ]
22
25
  CallbackFunc = Callable[..., Any]
23
26
 
@@ -36,6 +39,9 @@ _callbacks: Dict[PhaseType, List[CallbackFunc]] = {
36
39
  "custom_command": [],
37
40
  "custom_command_help": [],
38
41
  "file_permission": [],
42
+ "pre_tool_call": [],
43
+ "post_tool_call": [],
44
+ "stream_event": [],
39
45
  }
40
46
 
41
47
  logger = logging.getLogger(__name__)
@@ -271,3 +277,70 @@ def on_file_permission(
271
277
  message_group,
272
278
  operation_data,
273
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
+ )
@@ -156,6 +156,59 @@ class ChatGPTCodexAsyncClient(httpx.AsyncClient):
156
156
  }
157
157
  modified = True
158
158
 
159
+ # When `store=false` (Codex requirement), the backend does NOT persist input items.
160
+ # That means any later request that tries to reference a previous item by id will 404.
161
+ # We defensively strip reference-style items (especially reasoning_content) to avoid:
162
+ # "Item with id 'rs_...' not found. Items are not persisted when store is false."
163
+ input_items = data.get("input")
164
+ if data.get("store") is False and isinstance(input_items, list):
165
+ original_len = len(input_items)
166
+
167
+ def _looks_like_unpersisted_reference(it: dict) -> bool:
168
+ it_id = it.get("id")
169
+ if it_id in {"reasoning_content", "rs_reasoning_content"}:
170
+ return True
171
+
172
+ # Common reference-ish shapes: {"type": "input_item_reference", "id": "..."}
173
+ it_type = it.get("type")
174
+ if it_type in {"input_item_reference", "item_reference", "reference"}:
175
+ return True
176
+
177
+ # Ultra-conservative: if it's basically just an id (no actual content), drop it.
178
+ # A legit content item will typically have fields like `content`, `text`, `role`, etc.
179
+ non_id_keys = {k for k in it.keys() if k not in {"id", "type"}}
180
+ if not non_id_keys and isinstance(it_id, str) and it_id:
181
+ return True
182
+
183
+ return False
184
+
185
+ filtered: list[object] = []
186
+ for item in input_items:
187
+ if isinstance(item, dict) and _looks_like_unpersisted_reference(item):
188
+ modified = True
189
+ continue
190
+ filtered.append(item)
191
+
192
+ if len(filtered) != original_len:
193
+ data["input"] = filtered
194
+
195
+ # Normalize invalid input IDs (Codex expects reasoning ids to start with "rs_")
196
+ # Note: this is only safe for actual content items, NOT references.
197
+ input_items = data.get("input")
198
+ if isinstance(input_items, list):
199
+ for item in input_items:
200
+ if not isinstance(item, dict):
201
+ continue
202
+ item_id = item.get("id")
203
+ if (
204
+ isinstance(item_id, str)
205
+ and item_id
206
+ and "reasoning" in item_id
207
+ and not item_id.startswith("rs_")
208
+ ):
209
+ item["id"] = f"rs_{item_id}"
210
+ modified = True
211
+
159
212
  # Remove unsupported parameters
160
213
  # Note: verbosity should be under "text" object, not top-level
161
214
  unsupported_params = ["max_output_tokens", "max_tokens", "verbosity"]