gobby 0.2.6__py3-none-any.whl → 0.2.7__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 (146) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/codex_impl/__init__.py +28 -0
  4. gobby/adapters/codex_impl/adapter.py +722 -0
  5. gobby/adapters/codex_impl/client.py +679 -0
  6. gobby/adapters/codex_impl/protocol.py +20 -0
  7. gobby/adapters/codex_impl/types.py +68 -0
  8. gobby/agents/definitions.py +11 -1
  9. gobby/agents/isolation.py +395 -0
  10. gobby/agents/sandbox.py +261 -0
  11. gobby/agents/spawn.py +42 -287
  12. gobby/agents/spawn_executor.py +385 -0
  13. gobby/agents/spawners/__init__.py +24 -0
  14. gobby/agents/spawners/command_builder.py +189 -0
  15. gobby/agents/spawners/embedded.py +21 -2
  16. gobby/agents/spawners/headless.py +21 -2
  17. gobby/agents/spawners/prompt_manager.py +125 -0
  18. gobby/cli/install.py +4 -4
  19. gobby/cli/installers/claude.py +6 -0
  20. gobby/cli/installers/gemini.py +6 -0
  21. gobby/cli/installers/shared.py +103 -4
  22. gobby/cli/sessions.py +1 -1
  23. gobby/cli/utils.py +9 -2
  24. gobby/config/__init__.py +12 -97
  25. gobby/config/app.py +10 -94
  26. gobby/config/extensions.py +2 -2
  27. gobby/config/features.py +7 -130
  28. gobby/config/tasks.py +4 -28
  29. gobby/hooks/__init__.py +0 -13
  30. gobby/hooks/event_handlers.py +45 -2
  31. gobby/hooks/hook_manager.py +2 -2
  32. gobby/hooks/plugins.py +1 -1
  33. gobby/hooks/webhooks.py +1 -1
  34. gobby/llm/resolver.py +3 -2
  35. gobby/mcp_proxy/importer.py +62 -4
  36. gobby/mcp_proxy/instructions.py +2 -0
  37. gobby/mcp_proxy/registries.py +1 -4
  38. gobby/mcp_proxy/services/recommendation.py +43 -11
  39. gobby/mcp_proxy/tools/agents.py +31 -731
  40. gobby/mcp_proxy/tools/clones.py +0 -385
  41. gobby/mcp_proxy/tools/memory.py +2 -2
  42. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  43. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  44. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  45. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  46. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  47. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  48. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  49. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  50. gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
  51. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  52. gobby/mcp_proxy/tools/worktrees.py +0 -343
  53. gobby/memory/ingestion/__init__.py +5 -0
  54. gobby/memory/ingestion/multimodal.py +221 -0
  55. gobby/memory/manager.py +62 -283
  56. gobby/memory/search/__init__.py +10 -0
  57. gobby/memory/search/coordinator.py +248 -0
  58. gobby/memory/services/__init__.py +5 -0
  59. gobby/memory/services/crossref.py +142 -0
  60. gobby/prompts/loader.py +5 -2
  61. gobby/servers/http.py +1 -4
  62. gobby/servers/routes/admin.py +14 -0
  63. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  64. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  65. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  66. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  67. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  68. gobby/servers/routes/mcp/hooks.py +1 -1
  69. gobby/servers/routes/mcp/tools.py +48 -1506
  70. gobby/sessions/lifecycle.py +1 -1
  71. gobby/sessions/processor.py +10 -0
  72. gobby/sessions/transcripts/base.py +1 -0
  73. gobby/sessions/transcripts/claude.py +15 -5
  74. gobby/skills/parser.py +30 -2
  75. gobby/storage/migrations.py +159 -372
  76. gobby/storage/sessions.py +43 -7
  77. gobby/storage/skills.py +37 -4
  78. gobby/storage/tasks/_lifecycle.py +18 -3
  79. gobby/sync/memories.py +1 -1
  80. gobby/tasks/external_validator.py +1 -1
  81. gobby/tasks/validation.py +22 -20
  82. gobby/tools/summarizer.py +91 -10
  83. gobby/utils/project_context.py +2 -3
  84. gobby/utils/status.py +13 -0
  85. gobby/workflows/actions.py +221 -1217
  86. gobby/workflows/artifact_actions.py +31 -0
  87. gobby/workflows/autonomous_actions.py +11 -0
  88. gobby/workflows/context_actions.py +50 -1
  89. gobby/workflows/enforcement/__init__.py +47 -0
  90. gobby/workflows/enforcement/blocking.py +269 -0
  91. gobby/workflows/enforcement/commit_policy.py +283 -0
  92. gobby/workflows/enforcement/handlers.py +269 -0
  93. gobby/workflows/enforcement/task_policy.py +542 -0
  94. gobby/workflows/git_utils.py +106 -0
  95. gobby/workflows/llm_actions.py +30 -0
  96. gobby/workflows/mcp_actions.py +20 -1
  97. gobby/workflows/memory_actions.py +80 -0
  98. gobby/workflows/safe_evaluator.py +183 -0
  99. gobby/workflows/session_actions.py +44 -0
  100. gobby/workflows/state_actions.py +60 -1
  101. gobby/workflows/stop_signal_actions.py +55 -0
  102. gobby/workflows/summary_actions.py +94 -1
  103. gobby/workflows/task_sync_actions.py +347 -0
  104. gobby/workflows/todo_actions.py +34 -1
  105. gobby/workflows/webhook_actions.py +185 -0
  106. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
  107. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
  108. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  109. gobby/adapters/codex.py +0 -1332
  110. gobby/install/claude/commands/gobby/bug.md +0 -51
  111. gobby/install/claude/commands/gobby/chore.md +0 -51
  112. gobby/install/claude/commands/gobby/epic.md +0 -52
  113. gobby/install/claude/commands/gobby/eval.md +0 -235
  114. gobby/install/claude/commands/gobby/feat.md +0 -49
  115. gobby/install/claude/commands/gobby/nit.md +0 -52
  116. gobby/install/claude/commands/gobby/ref.md +0 -52
  117. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  118. gobby/prompts/defaults/expansion/system.md +0 -119
  119. gobby/prompts/defaults/expansion/user.md +0 -48
  120. gobby/prompts/defaults/external_validation/agent.md +0 -72
  121. gobby/prompts/defaults/external_validation/external.md +0 -63
  122. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  123. gobby/prompts/defaults/external_validation/system.md +0 -6
  124. gobby/prompts/defaults/features/import_mcp.md +0 -22
  125. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  126. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  127. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  128. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  129. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  130. gobby/prompts/defaults/features/server_description.md +0 -20
  131. gobby/prompts/defaults/features/server_description_system.md +0 -6
  132. gobby/prompts/defaults/features/task_description.md +0 -31
  133. gobby/prompts/defaults/features/task_description_system.md +0 -6
  134. gobby/prompts/defaults/features/tool_summary.md +0 -17
  135. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  136. gobby/prompts/defaults/handoff/compact.md +0 -63
  137. gobby/prompts/defaults/handoff/session_end.md +0 -57
  138. gobby/prompts/defaults/memory/extract.md +0 -61
  139. gobby/prompts/defaults/research/step.md +0 -58
  140. gobby/prompts/defaults/validation/criteria.md +0 -47
  141. gobby/prompts/defaults/validation/validate.md +0 -38
  142. gobby/storage/migrations_legacy.py +0 -1359
  143. gobby/workflows/task_enforcement_actions.py +0 -1343
  144. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  145. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  146. {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,679 @@
1
+ """
2
+ CodexAppServerClient implementation.
3
+
4
+ Extracted from codex.py as part of Phase 3 Strangler Fig decomposition.
5
+ This module contains the CodexAppServerClient for communicating with
6
+ the Codex app-server subprocess via JSON-RPC.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import subprocess # nosec B404 - subprocess needed for Codex app-server process
15
+ import threading
16
+ from collections.abc import AsyncIterator
17
+ from pathlib import Path
18
+ from typing import Any, cast
19
+
20
+ from gobby.adapters.codex_impl.types import (
21
+ CodexConnectionState,
22
+ CodexThread,
23
+ CodexTurn,
24
+ NotificationHandler,
25
+ )
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Codex session storage location
30
+ CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
31
+
32
+
33
+ class CodexAppServerClient:
34
+ """
35
+ Client for the Codex app-server JSON-RPC protocol.
36
+
37
+ Manages the subprocess lifecycle and provides async methods for:
38
+ - Thread management (conversations)
39
+ - Turn management (message exchanges)
40
+ - Event streaming via notifications
41
+
42
+ Example:
43
+ async with CodexAppServerClient() as client:
44
+ thread = await client.start_thread(cwd="/path/to/project")
45
+ async for event in client.run_turn(thread.id, "Help me refactor"):
46
+ print(event)
47
+ """
48
+
49
+ CLIENT_NAME = "gobby-daemon"
50
+ CLIENT_TITLE = "Gobby Daemon"
51
+ CLIENT_VERSION = "0.1.0"
52
+
53
+ def __init__(
54
+ self,
55
+ codex_command: str = "codex",
56
+ on_notification: NotificationHandler | None = None,
57
+ ) -> None:
58
+ """
59
+ Initialize the Codex app-server client.
60
+
61
+ Args:
62
+ codex_command: Path to the codex binary (default: "codex")
63
+ on_notification: Optional callback for all notifications
64
+ """
65
+ self._codex_command = codex_command
66
+ self._on_notification = on_notification
67
+
68
+ self._process: subprocess.Popen[str] | None = None
69
+ self._state = CodexConnectionState.DISCONNECTED
70
+ self._request_id = 0
71
+ self._request_id_lock = threading.Lock()
72
+
73
+ # Pending requests waiting for responses
74
+ self._pending_requests: dict[int, asyncio.Future[Any]] = {}
75
+ self._pending_requests_lock = threading.Lock()
76
+
77
+ # Notification handlers by method
78
+ self._notification_handlers: dict[str, list[NotificationHandler]] = {}
79
+
80
+ # Reader task
81
+ self._reader_task: asyncio.Task[None] | None = None
82
+ self._shutdown_event = asyncio.Event()
83
+
84
+ # Thread tracking for session management
85
+ self._threads: dict[str, CodexThread] = {}
86
+
87
+ @property
88
+ def state(self) -> CodexConnectionState:
89
+ """Get current connection state."""
90
+ return self._state
91
+
92
+ @property
93
+ def is_connected(self) -> bool:
94
+ """Check if connected to app-server."""
95
+ return self._state == CodexConnectionState.CONNECTED
96
+
97
+ async def __aenter__(self) -> CodexAppServerClient:
98
+ """Async context manager entry - starts the app-server."""
99
+ await self.start()
100
+ return self
101
+
102
+ async def __aexit__(
103
+ self,
104
+ exc_type: type[BaseException] | None,
105
+ exc_val: BaseException | None,
106
+ exc_tb: object,
107
+ ) -> None:
108
+ """Async context manager exit - stops the app-server."""
109
+ await self.stop()
110
+
111
+ async def start(self) -> None:
112
+ """
113
+ Start the Codex app-server subprocess and initialize connection.
114
+
115
+ Raises:
116
+ RuntimeError: If already connected or failed to start
117
+ """
118
+ if self._state == CodexConnectionState.CONNECTED:
119
+ logger.warning("CodexAppServerClient already connected")
120
+ return
121
+
122
+ self._state = CodexConnectionState.CONNECTING
123
+ logger.debug("Starting Codex app-server...")
124
+
125
+ try:
126
+ # Start the subprocess
127
+ self._process = subprocess.Popen( # nosec B603 - hardcoded argument list
128
+ [self._codex_command, "app-server"],
129
+ stdin=subprocess.PIPE,
130
+ stdout=subprocess.PIPE,
131
+ stderr=subprocess.PIPE,
132
+ text=True,
133
+ bufsize=1, # Line buffered
134
+ )
135
+
136
+ # Start the reader task
137
+ self._shutdown_event.clear()
138
+ self._reader_task = asyncio.create_task(self._read_loop())
139
+
140
+ # Send initialize request
141
+ result = await self._send_request(
142
+ "initialize",
143
+ {
144
+ "clientInfo": {
145
+ "name": self.CLIENT_NAME,
146
+ "title": self.CLIENT_TITLE,
147
+ "version": self.CLIENT_VERSION,
148
+ }
149
+ },
150
+ )
151
+
152
+ user_agent = result.get("userAgent", "unknown")
153
+ logger.debug(f"Codex app-server initialized: {user_agent}")
154
+
155
+ # Send initialized notification
156
+ await self._send_notification("initialized", {})
157
+
158
+ self._state = CodexConnectionState.CONNECTED
159
+ logger.debug("Codex app-server connection established")
160
+
161
+ except Exception as e:
162
+ self._state = CodexConnectionState.ERROR
163
+ logger.error(f"Failed to start Codex app-server: {e}", exc_info=True)
164
+ await self.stop()
165
+ raise RuntimeError(f"Failed to start Codex app-server: {e}") from e
166
+
167
+ async def stop(self) -> None:
168
+ """Stop the Codex app-server subprocess."""
169
+ logger.debug("Stopping Codex app-server...")
170
+
171
+ self._shutdown_event.set()
172
+
173
+ # Cancel reader task
174
+ if self._reader_task and not self._reader_task.done():
175
+ self._reader_task.cancel()
176
+ try:
177
+ await self._reader_task
178
+ except asyncio.CancelledError:
179
+ pass
180
+
181
+ # Terminate process
182
+ if self._process:
183
+ try:
184
+ if self._process.stdin:
185
+ self._process.stdin.close()
186
+ self._process.terminate()
187
+ loop = asyncio.get_running_loop()
188
+ await asyncio.wait_for(loop.run_in_executor(None, self._process.wait), timeout=5.0)
189
+ except Exception as e:
190
+ logger.warning(f"Error terminating Codex app-server: {e}")
191
+ self._process.kill()
192
+ finally:
193
+ self._process = None
194
+
195
+ # Cancel pending requests
196
+ with self._pending_requests_lock:
197
+ for future in self._pending_requests.values():
198
+ if not future.done():
199
+ future.cancel()
200
+ self._pending_requests.clear()
201
+
202
+ self._state = CodexConnectionState.DISCONNECTED
203
+ logger.debug("Codex app-server stopped")
204
+
205
+ def add_notification_handler(self, method: str, handler: NotificationHandler) -> None:
206
+ """
207
+ Register a handler for a specific notification method.
208
+
209
+ Args:
210
+ method: Notification method name (e.g., "turn/started", "item/completed")
211
+ handler: Callback function(method, params)
212
+ """
213
+ if method not in self._notification_handlers:
214
+ self._notification_handlers[method] = []
215
+ self._notification_handlers[method].append(handler)
216
+
217
+ def remove_notification_handler(self, method: str, handler: NotificationHandler) -> None:
218
+ """Remove a notification handler."""
219
+ if method in self._notification_handlers:
220
+ self._notification_handlers[method] = [
221
+ h for h in self._notification_handlers[method] if h != handler
222
+ ]
223
+
224
+ # ===== Thread Management =====
225
+
226
+ async def start_thread(
227
+ self,
228
+ cwd: str | None = None,
229
+ model: str | None = None,
230
+ approval_policy: str | None = None,
231
+ sandbox: str | None = None,
232
+ ) -> CodexThread:
233
+ """
234
+ Start a new Codex conversation thread.
235
+
236
+ Args:
237
+ cwd: Working directory for the session
238
+ model: Model override (e.g., "gpt-5.1-codex")
239
+ approval_policy: Approval policy ("never", "unlessTrusted", etc.)
240
+ sandbox: Sandbox mode ("workspaceWrite", "readOnly", etc.)
241
+
242
+ Returns:
243
+ CodexThread object with thread ID
244
+ """
245
+ params: dict[str, Any] = {}
246
+ if cwd:
247
+ params["cwd"] = cwd
248
+ if model:
249
+ params["model"] = model
250
+ if approval_policy:
251
+ params["approvalPolicy"] = approval_policy
252
+ if sandbox:
253
+ params["sandbox"] = sandbox
254
+
255
+ result = await self._send_request("thread/start", params)
256
+
257
+ thread_data = result.get("thread", {})
258
+ thread = CodexThread(
259
+ id=thread_data.get("id", ""),
260
+ preview=thread_data.get("preview", ""),
261
+ model_provider=thread_data.get("modelProvider", "openai"),
262
+ created_at=thread_data.get("createdAt", 0),
263
+ )
264
+
265
+ self._threads[thread.id] = thread
266
+ logger.debug(f"Started Codex thread: {thread.id}")
267
+ return thread
268
+
269
+ async def resume_thread(self, thread_id: str) -> CodexThread:
270
+ """
271
+ Resume an existing Codex conversation thread.
272
+
273
+ Args:
274
+ thread_id: ID of the thread to resume
275
+
276
+ Returns:
277
+ CodexThread object
278
+ """
279
+ result = await self._send_request("thread/resume", {"threadId": thread_id})
280
+
281
+ thread_data = result.get("thread", {})
282
+ thread = CodexThread(
283
+ id=thread_data.get("id", thread_id),
284
+ preview=thread_data.get("preview", ""),
285
+ model_provider=thread_data.get("modelProvider", "openai"),
286
+ created_at=thread_data.get("createdAt", 0),
287
+ )
288
+
289
+ self._threads[thread.id] = thread
290
+ logger.debug(f"Resumed Codex thread: {thread.id}")
291
+ return thread
292
+
293
+ async def list_threads(
294
+ self, cursor: str | None = None, limit: int = 25
295
+ ) -> tuple[list[CodexThread], str | None]:
296
+ """
297
+ List stored Codex threads with pagination.
298
+
299
+ Args:
300
+ cursor: Pagination cursor from previous call
301
+ limit: Maximum threads to return
302
+
303
+ Returns:
304
+ Tuple of (threads list, next_cursor or None)
305
+ """
306
+ params: dict[str, Any] = {"limit": limit}
307
+ if cursor:
308
+ params["cursor"] = cursor
309
+
310
+ result = await self._send_request("thread/list", params)
311
+
312
+ threads = []
313
+ for item in result.get("data", []):
314
+ threads.append(
315
+ CodexThread(
316
+ id=item.get("id", ""),
317
+ preview=item.get("preview", ""),
318
+ model_provider=item.get("modelProvider", "openai"),
319
+ created_at=item.get("createdAt", 0),
320
+ )
321
+ )
322
+
323
+ next_cursor = result.get("nextCursor")
324
+ return threads, next_cursor
325
+
326
+ async def archive_thread(self, thread_id: str) -> None:
327
+ """
328
+ Archive a Codex thread.
329
+
330
+ Args:
331
+ thread_id: ID of the thread to archive
332
+ """
333
+ await self._send_request("thread/archive", {"threadId": thread_id})
334
+ self._threads.pop(thread_id, None)
335
+ logger.debug(f"Archived Codex thread: {thread_id}")
336
+
337
+ # ===== Turn Management =====
338
+
339
+ async def start_turn(
340
+ self,
341
+ thread_id: str,
342
+ prompt: str,
343
+ images: list[str] | None = None,
344
+ **config_overrides: Any,
345
+ ) -> CodexTurn:
346
+ """
347
+ Start a new turn (send user input and trigger generation).
348
+
349
+ Args:
350
+ thread_id: Thread ID to add turn to
351
+ prompt: User's input text
352
+ images: Optional list of image paths or URLs
353
+ **config_overrides: Optional config overrides (cwd, model, etc.)
354
+
355
+ Returns:
356
+ CodexTurn object (initial state, updates via notifications)
357
+ """
358
+ # Build input array
359
+ inputs: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
360
+
361
+ if images:
362
+ for img in images:
363
+ if img.startswith(("http://", "https://")):
364
+ inputs.append({"type": "image", "url": img})
365
+ else:
366
+ inputs.append({"type": "localImage", "path": img})
367
+
368
+ params: dict[str, Any] = {
369
+ "threadId": thread_id,
370
+ "input": inputs,
371
+ }
372
+ params.update(config_overrides)
373
+
374
+ result = await self._send_request("turn/start", params)
375
+
376
+ turn_data = result.get("turn", {})
377
+ turn = CodexTurn(
378
+ id=turn_data.get("id", ""),
379
+ thread_id=thread_id,
380
+ status=turn_data.get("status", "inProgress"),
381
+ items=turn_data.get("items", []),
382
+ error=turn_data.get("error"),
383
+ )
384
+
385
+ logger.debug(f"Started turn {turn.id} in thread {thread_id}")
386
+ return turn
387
+
388
+ async def interrupt_turn(self, thread_id: str, turn_id: str) -> None:
389
+ """
390
+ Interrupt an in-progress turn.
391
+
392
+ Args:
393
+ thread_id: Thread ID containing the turn
394
+ turn_id: Turn ID to interrupt
395
+ """
396
+ await self._send_request("turn/interrupt", {"threadId": thread_id, "turnId": turn_id})
397
+ logger.debug(f"Interrupted turn {turn_id}")
398
+
399
+ async def run_turn(
400
+ self,
401
+ thread_id: str,
402
+ prompt: str,
403
+ images: list[str] | None = None,
404
+ **config_overrides: Any,
405
+ ) -> AsyncIterator[dict[str, Any]]:
406
+ """
407
+ Run a turn and yield streaming events.
408
+
409
+ This is the primary method for interacting with Codex. It starts a turn
410
+ and yields all events until completion.
411
+
412
+ Args:
413
+ thread_id: Thread ID
414
+ prompt: User's input text
415
+ images: Optional image paths/URLs
416
+ **config_overrides: Config overrides
417
+
418
+ Yields:
419
+ Event dicts with "type" and event-specific data
420
+
421
+ Example:
422
+ async for event in client.run_turn(thread.id, "Help me refactor"):
423
+ if event["type"] == "item.completed":
424
+ print(event["item"]["text"])
425
+ """
426
+ # Queue to receive notifications
427
+ event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
428
+ turn_completed = asyncio.Event()
429
+
430
+ def on_event(method: str, params: dict[str, Any]) -> None:
431
+ event_queue.put_nowait({"type": method, **params})
432
+ if method == "turn/completed":
433
+ turn_completed.set()
434
+
435
+ # Register handlers for all turn-related events
436
+ event_methods = [
437
+ "turn/started",
438
+ "turn/completed",
439
+ "item/started",
440
+ "item/completed",
441
+ "item/agentMessage/delta",
442
+ ]
443
+
444
+ for method in event_methods:
445
+ self.add_notification_handler(method, on_event)
446
+
447
+ try:
448
+ # Start the turn
449
+ turn = await self.start_turn(thread_id, prompt, images=images, **config_overrides)
450
+
451
+ yield {"type": "turn/created", "turn": turn.__dict__}
452
+
453
+ # Yield events until turn completes
454
+ while not turn_completed.is_set():
455
+ try:
456
+ event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
457
+ yield event
458
+ except TimeoutError:
459
+ continue
460
+
461
+ # Drain remaining events
462
+ while not event_queue.empty():
463
+ yield event_queue.get_nowait()
464
+
465
+ finally:
466
+ # Unregister handlers
467
+ for method in event_methods:
468
+ self.remove_notification_handler(method, on_event)
469
+
470
+ # ===== Authentication =====
471
+
472
+ async def login_with_api_key(self, api_key: str) -> dict[str, Any]:
473
+ """
474
+ Authenticate using an OpenAI API key.
475
+
476
+ Args:
477
+ api_key: OpenAI API key (sk-...)
478
+
479
+ Returns:
480
+ Login result dict
481
+ """
482
+ result = await self._send_request(
483
+ "account/login/start", {"type": "apiKey", "apiKey": api_key}
484
+ )
485
+ logger.debug("Logged in with API key")
486
+ return result
487
+
488
+ async def get_account_status(self) -> dict[str, Any]:
489
+ """Get current account/authentication status."""
490
+ return await self._send_request("account/status", {})
491
+
492
+ # ===== Internal Methods =====
493
+
494
+ def _next_request_id(self) -> int:
495
+ """Generate unique request ID."""
496
+ with self._request_id_lock:
497
+ self._request_id += 1
498
+ return self._request_id
499
+
500
+ async def _send_request(
501
+ self, method: str, params: dict[str, Any], timeout: float = 60.0
502
+ ) -> dict[str, Any]:
503
+ """
504
+ Send a JSON-RPC request and wait for response.
505
+
506
+ Args:
507
+ method: RPC method name
508
+ params: Method parameters
509
+ timeout: Response timeout in seconds
510
+
511
+ Returns:
512
+ Result dict from response
513
+
514
+ Raises:
515
+ RuntimeError: If not connected or request fails
516
+ TimeoutError: If response times out
517
+ """
518
+ if not self._process or not self._process.stdin:
519
+ raise RuntimeError("Not connected to Codex app-server")
520
+
521
+ request_id = self._next_request_id()
522
+ request = {
523
+ "jsonrpc": "2.0",
524
+ "method": method,
525
+ "id": request_id,
526
+ "params": params,
527
+ }
528
+
529
+ # Create future for response
530
+ loop = asyncio.get_running_loop()
531
+ future: asyncio.Future[Any] = loop.create_future()
532
+
533
+ with self._pending_requests_lock:
534
+ self._pending_requests[request_id] = future
535
+
536
+ try:
537
+ # Send request - offload blocking I/O to thread executor
538
+ request_line = json.dumps(request) + "\n"
539
+
540
+ # Capture local references to avoid race with stop()
541
+ process = self._process
542
+ stdin = process.stdin if process is not None else None
543
+
544
+ def write_request() -> None:
545
+ if stdin is None:
546
+ return
547
+ stdin.write(request_line)
548
+ stdin.flush()
549
+
550
+ if stdin is None:
551
+ raise RuntimeError("Not connected to Codex app-server")
552
+
553
+ await loop.run_in_executor(None, write_request)
554
+
555
+ logger.debug(f"Sent request: {method} (id={request_id})")
556
+
557
+ # Wait for response
558
+ result = await asyncio.wait_for(future, timeout=timeout)
559
+ return cast(dict[str, Any], result)
560
+
561
+ except TimeoutError:
562
+ logger.error(f"Request {method} (id={request_id}) timed out")
563
+ raise
564
+ finally:
565
+ with self._pending_requests_lock:
566
+ self._pending_requests.pop(request_id, None)
567
+
568
+ async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
569
+ """Send a JSON-RPC notification (no response expected)."""
570
+ if not self._process or not self._process.stdin:
571
+ raise RuntimeError("Not connected to Codex app-server")
572
+
573
+ notification = {"jsonrpc": "2.0", "method": method, "params": params}
574
+
575
+ notification_line = json.dumps(notification) + "\n"
576
+
577
+ # Capture local references to avoid race with stop()
578
+ process = self._process
579
+ stdin = process.stdin if process is not None else None
580
+
581
+ def write_notification() -> None:
582
+ if stdin is None:
583
+ return
584
+ stdin.write(notification_line)
585
+ stdin.flush()
586
+
587
+ if stdin is None:
588
+ raise RuntimeError("Not connected to Codex app-server")
589
+
590
+ loop = asyncio.get_running_loop()
591
+ await loop.run_in_executor(None, write_notification)
592
+
593
+ logger.debug(f"Sent notification: {method}")
594
+
595
+ async def _read_loop(self) -> None:
596
+ """Background task to read responses and notifications."""
597
+ if not self._process or not self._process.stdout:
598
+ return
599
+
600
+ loop = asyncio.get_running_loop()
601
+
602
+ while not self._shutdown_event.is_set():
603
+ try:
604
+ # Capture local references to avoid race with stop()
605
+ proc = self._process
606
+ if proc is None:
607
+ break
608
+ stdout = proc.stdout
609
+ if stdout is None:
610
+ break
611
+
612
+ # Read line in thread pool to avoid blocking
613
+ line = await loop.run_in_executor(None, stdout.readline)
614
+
615
+ if not line:
616
+ if proc.poll() is not None:
617
+ logger.warning("Codex app-server process terminated")
618
+ self._state = CodexConnectionState.ERROR
619
+ break
620
+ continue
621
+
622
+ # Parse JSON-RPC message
623
+ try:
624
+ message = json.loads(line.strip())
625
+ except json.JSONDecodeError as e:
626
+ logger.warning(f"Invalid JSON from app-server: {e}")
627
+ continue
628
+
629
+ # Handle response (has "id")
630
+ if "id" in message:
631
+ request_id = message["id"]
632
+ with self._pending_requests_lock:
633
+ future = self._pending_requests.get(request_id)
634
+
635
+ if future and not future.done():
636
+ if "error" in message:
637
+ error = message["error"]
638
+ future.set_exception(
639
+ RuntimeError(
640
+ f"RPC error {error.get('code')}: {error.get('message')}"
641
+ )
642
+ )
643
+ else:
644
+ future.set_result(message.get("result", {}))
645
+
646
+ # Handle notification (no "id")
647
+ elif "method" in message:
648
+ method = message["method"]
649
+ params = message.get("params", {})
650
+
651
+ logger.debug(f"Received notification: {method}")
652
+
653
+ # Call global handler
654
+ if self._on_notification:
655
+ try:
656
+ self._on_notification(method, params)
657
+ except Exception as e:
658
+ logger.error(f"Notification handler error: {e}")
659
+
660
+ # Call method-specific handlers
661
+ handlers = self._notification_handlers.get(method, [])
662
+ for handler in handlers:
663
+ try:
664
+ handler(method, params)
665
+ except Exception as e:
666
+ logger.error(f"Handler error for {method}: {e}")
667
+
668
+ except asyncio.CancelledError:
669
+ break
670
+ except Exception as e:
671
+ logger.error(f"Error in read loop: {e}", exc_info=True)
672
+ if self._shutdown_event.is_set():
673
+ break
674
+
675
+
676
+ __all__ = [
677
+ "CodexAppServerClient",
678
+ "CODEX_SESSIONS_DIR",
679
+ ]
@@ -0,0 +1,20 @@
1
+ """
2
+ Protocol definitions for Codex adapter.
3
+
4
+ Target protocols to migrate from codex.py:
5
+ - Abstract base classes defining adapter interfaces
6
+ - Protocol classes for type checking
7
+ - Base class methods that define the contract
8
+
9
+ Dependencies:
10
+ - abc (ABC, abstractmethod)
11
+ - typing (Protocol)
12
+ """
13
+
14
+ from typing import TYPE_CHECKING
15
+
16
+ if TYPE_CHECKING:
17
+ pass
18
+
19
+ # Placeholder - protocols will be migrated from codex.py
20
+ __all__: list[str] = []