gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.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] = []