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
gobby/adapters/codex.py DELETED
@@ -1,1332 +0,0 @@
1
- """Codex CLI integration for gobby-daemon.
2
-
3
- This module provides two integration modes for Codex CLI:
4
-
5
- 1. App-Server Mode (programmatic control):
6
- - CodexAppServerClient: Spawns `codex app-server` subprocess
7
- - CodexAdapter: Translates app-server events to HookEvent
8
- - Full control over threads, turns, and streaming events
9
-
10
- 2. Notify Mode (installed hooks via `gobby install --codex`):
11
- - CodexNotifyAdapter: Handles HTTP webhooks from Codex notify config
12
- - Fire-and-forget events on agent-turn-complete
13
-
14
- Architecture:
15
- App-Server Mode:
16
- gobby-daemon
17
- └── CodexAppServerClient
18
- ├── Spawns: `codex app-server` (stdio subprocess)
19
- ├── Protocol: JSON-RPC 2.0 over stdin/stdout
20
- └── CodexAdapter (translates events to HookEvent)
21
-
22
- Notify Mode:
23
- Codex CLI
24
- └── notify script (installed by `gobby install --codex`)
25
- └── HTTP POST to /hooks/execute
26
- └── CodexNotifyAdapter (translates to HookEvent)
27
-
28
- See: https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md
29
- """
30
-
31
- from __future__ import annotations
32
-
33
- import asyncio
34
- import glob as glob_module
35
- import json
36
- import logging
37
- import os
38
- import platform
39
- import subprocess # nosec B404 - subprocess needed for Codex app-server process
40
- import threading
41
- import uuid
42
- from collections.abc import AsyncIterator, Callable
43
- from dataclasses import dataclass, field
44
- from datetime import UTC, datetime
45
- from enum import Enum
46
- from pathlib import Path
47
- from typing import TYPE_CHECKING, Any, cast
48
-
49
- from gobby.adapters.base import BaseAdapter
50
- from gobby.hooks.events import HookEvent, HookEventType, HookResponse, SessionSource
51
-
52
- if TYPE_CHECKING:
53
- from gobby.hooks.hook_manager import HookManager
54
-
55
- logger = logging.getLogger(__name__)
56
-
57
- # Codex session storage location
58
- CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
59
-
60
-
61
- # =============================================================================
62
- # App-Server Data Types
63
- # =============================================================================
64
-
65
-
66
- class CodexConnectionState(Enum):
67
- """Connection state for the Codex app-server."""
68
-
69
- DISCONNECTED = "disconnected"
70
- CONNECTING = "connecting"
71
- CONNECTED = "connected"
72
- ERROR = "error"
73
-
74
-
75
- @dataclass
76
- class CodexThread:
77
- """Represents a Codex conversation thread."""
78
-
79
- id: str
80
- preview: str = ""
81
- model_provider: str = "openai"
82
- created_at: int = 0
83
-
84
-
85
- @dataclass
86
- class CodexTurn:
87
- """Represents a turn in a Codex conversation."""
88
-
89
- id: str
90
- thread_id: str
91
- status: str = "pending"
92
- items: list[dict[str, Any]] = field(default_factory=list)
93
- error: str | None = None
94
- usage: dict[str, int] | None = None
95
-
96
-
97
- @dataclass
98
- class CodexItem:
99
- """Represents an item (message, tool call, etc.) in a turn."""
100
-
101
- id: str
102
- type: str # "reasoning", "agent_message", "command_execution", "user_message", etc.
103
- content: str = ""
104
- status: str = "pending"
105
- metadata: dict[str, Any] = field(default_factory=dict)
106
-
107
-
108
- # Type alias for notification handlers
109
- NotificationHandler = Callable[[str, dict[str, Any]], None]
110
-
111
-
112
- # =============================================================================
113
- # App-Server Client (Programmatic Control)
114
- # =============================================================================
115
-
116
-
117
- class CodexAppServerClient:
118
- """
119
- Client for the Codex app-server JSON-RPC protocol.
120
-
121
- Manages the subprocess lifecycle and provides async methods for:
122
- - Thread management (conversations)
123
- - Turn management (message exchanges)
124
- - Event streaming via notifications
125
-
126
- Example:
127
- async with CodexAppServerClient() as client:
128
- thread = await client.start_thread(cwd="/path/to/project")
129
- async for event in client.run_turn(thread.id, "Help me refactor"):
130
- print(event)
131
- """
132
-
133
- CLIENT_NAME = "gobby-daemon"
134
- CLIENT_TITLE = "Gobby Daemon"
135
- CLIENT_VERSION = "0.1.0"
136
-
137
- def __init__(
138
- self,
139
- codex_command: str = "codex",
140
- on_notification: NotificationHandler | None = None,
141
- ) -> None:
142
- """
143
- Initialize the Codex app-server client.
144
-
145
- Args:
146
- codex_command: Path to the codex binary (default: "codex")
147
- on_notification: Optional callback for all notifications
148
- """
149
- self._codex_command = codex_command
150
- self._on_notification = on_notification
151
-
152
- self._process: subprocess.Popen[str] | None = None
153
- self._state = CodexConnectionState.DISCONNECTED
154
- self._request_id = 0
155
- self._request_id_lock = threading.Lock()
156
-
157
- # Pending requests waiting for responses
158
- self._pending_requests: dict[int, asyncio.Future[Any]] = {}
159
- self._pending_requests_lock = threading.Lock()
160
-
161
- # Notification handlers by method
162
- self._notification_handlers: dict[str, list[NotificationHandler]] = {}
163
-
164
- # Reader task
165
- self._reader_task: asyncio.Task[None] | None = None
166
- self._shutdown_event = asyncio.Event()
167
-
168
- # Thread tracking for session management
169
- self._threads: dict[str, CodexThread] = {}
170
-
171
- @property
172
- def state(self) -> CodexConnectionState:
173
- """Get current connection state."""
174
- return self._state
175
-
176
- @property
177
- def is_connected(self) -> bool:
178
- """Check if connected to app-server."""
179
- return self._state == CodexConnectionState.CONNECTED
180
-
181
- async def __aenter__(self) -> CodexAppServerClient:
182
- """Async context manager entry - starts the app-server."""
183
- await self.start()
184
- return self
185
-
186
- async def __aexit__(
187
- self,
188
- exc_type: type[BaseException] | None,
189
- exc_val: BaseException | None,
190
- exc_tb: object,
191
- ) -> None:
192
- """Async context manager exit - stops the app-server."""
193
- await self.stop()
194
-
195
- async def start(self) -> None:
196
- """
197
- Start the Codex app-server subprocess and initialize connection.
198
-
199
- Raises:
200
- RuntimeError: If already connected or failed to start
201
- """
202
- if self._state == CodexConnectionState.CONNECTED:
203
- logger.warning("CodexAppServerClient already connected")
204
- return
205
-
206
- self._state = CodexConnectionState.CONNECTING
207
- logger.debug("Starting Codex app-server...")
208
-
209
- try:
210
- # Start the subprocess
211
- self._process = subprocess.Popen( # nosec B603 - hardcoded argument list
212
- [self._codex_command, "app-server"],
213
- stdin=subprocess.PIPE,
214
- stdout=subprocess.PIPE,
215
- stderr=subprocess.PIPE,
216
- text=True,
217
- bufsize=1, # Line buffered
218
- )
219
-
220
- # Start the reader task
221
- self._shutdown_event.clear()
222
- self._reader_task = asyncio.create_task(self._read_loop())
223
-
224
- # Send initialize request
225
- result = await self._send_request(
226
- "initialize",
227
- {
228
- "clientInfo": {
229
- "name": self.CLIENT_NAME,
230
- "title": self.CLIENT_TITLE,
231
- "version": self.CLIENT_VERSION,
232
- }
233
- },
234
- )
235
-
236
- user_agent = result.get("userAgent", "unknown")
237
- logger.debug(f"Codex app-server initialized: {user_agent}")
238
-
239
- # Send initialized notification
240
- await self._send_notification("initialized", {})
241
-
242
- self._state = CodexConnectionState.CONNECTED
243
- logger.debug("Codex app-server connection established")
244
-
245
- except Exception as e:
246
- self._state = CodexConnectionState.ERROR
247
- logger.error(f"Failed to start Codex app-server: {e}", exc_info=True)
248
- await self.stop()
249
- raise RuntimeError(f"Failed to start Codex app-server: {e}") from e
250
-
251
- async def stop(self) -> None:
252
- """Stop the Codex app-server subprocess."""
253
- logger.debug("Stopping Codex app-server...")
254
-
255
- self._shutdown_event.set()
256
-
257
- # Cancel reader task
258
- if self._reader_task and not self._reader_task.done():
259
- self._reader_task.cancel()
260
- try:
261
- await self._reader_task
262
- except asyncio.CancelledError:
263
- pass
264
-
265
- # Terminate process
266
- if self._process:
267
- try:
268
- if self._process.stdin:
269
- self._process.stdin.close()
270
- self._process.terminate()
271
- loop = asyncio.get_event_loop()
272
- await asyncio.wait_for(loop.run_in_executor(None, self._process.wait), timeout=5.0)
273
- except Exception as e:
274
- logger.warning(f"Error terminating Codex app-server: {e}")
275
- self._process.kill()
276
- finally:
277
- self._process = None
278
-
279
- # Cancel pending requests
280
- with self._pending_requests_lock:
281
- for future in self._pending_requests.values():
282
- if not future.done():
283
- future.cancel()
284
- self._pending_requests.clear()
285
-
286
- self._state = CodexConnectionState.DISCONNECTED
287
- logger.debug("Codex app-server stopped")
288
-
289
- def add_notification_handler(self, method: str, handler: NotificationHandler) -> None:
290
- """
291
- Register a handler for a specific notification method.
292
-
293
- Args:
294
- method: Notification method name (e.g., "turn/started", "item/completed")
295
- handler: Callback function(method, params)
296
- """
297
- if method not in self._notification_handlers:
298
- self._notification_handlers[method] = []
299
- self._notification_handlers[method].append(handler)
300
-
301
- def remove_notification_handler(self, method: str, handler: NotificationHandler) -> None:
302
- """Remove a notification handler."""
303
- if method in self._notification_handlers:
304
- self._notification_handlers[method] = [
305
- h for h in self._notification_handlers[method] if h != handler
306
- ]
307
-
308
- # ===== Thread Management =====
309
-
310
- async def start_thread(
311
- self,
312
- cwd: str | None = None,
313
- model: str | None = None,
314
- approval_policy: str | None = None,
315
- sandbox: str | None = None,
316
- ) -> CodexThread:
317
- """
318
- Start a new Codex conversation thread.
319
-
320
- Args:
321
- cwd: Working directory for the session
322
- model: Model override (e.g., "gpt-5.1-codex")
323
- approval_policy: Approval policy ("never", "unlessTrusted", etc.)
324
- sandbox: Sandbox mode ("workspaceWrite", "readOnly", etc.)
325
-
326
- Returns:
327
- CodexThread object with thread ID
328
- """
329
- params: dict[str, Any] = {}
330
- if cwd:
331
- params["cwd"] = cwd
332
- if model:
333
- params["model"] = model
334
- if approval_policy:
335
- params["approvalPolicy"] = approval_policy
336
- if sandbox:
337
- params["sandbox"] = sandbox
338
-
339
- result = await self._send_request("thread/start", params)
340
-
341
- thread_data = result.get("thread", {})
342
- thread = CodexThread(
343
- id=thread_data.get("id", ""),
344
- preview=thread_data.get("preview", ""),
345
- model_provider=thread_data.get("modelProvider", "openai"),
346
- created_at=thread_data.get("createdAt", 0),
347
- )
348
-
349
- self._threads[thread.id] = thread
350
- logger.debug(f"Started Codex thread: {thread.id}")
351
- return thread
352
-
353
- async def resume_thread(self, thread_id: str) -> CodexThread:
354
- """
355
- Resume an existing Codex conversation thread.
356
-
357
- Args:
358
- thread_id: ID of the thread to resume
359
-
360
- Returns:
361
- CodexThread object
362
- """
363
- result = await self._send_request("thread/resume", {"threadId": thread_id})
364
-
365
- thread_data = result.get("thread", {})
366
- thread = CodexThread(
367
- id=thread_data.get("id", thread_id),
368
- preview=thread_data.get("preview", ""),
369
- model_provider=thread_data.get("modelProvider", "openai"),
370
- created_at=thread_data.get("createdAt", 0),
371
- )
372
-
373
- self._threads[thread.id] = thread
374
- logger.debug(f"Resumed Codex thread: {thread.id}")
375
- return thread
376
-
377
- async def list_threads(
378
- self, cursor: str | None = None, limit: int = 25
379
- ) -> tuple[list[CodexThread], str | None]:
380
- """
381
- List stored Codex threads with pagination.
382
-
383
- Args:
384
- cursor: Pagination cursor from previous call
385
- limit: Maximum threads to return
386
-
387
- Returns:
388
- Tuple of (threads list, next_cursor or None)
389
- """
390
- params: dict[str, Any] = {"limit": limit}
391
- if cursor:
392
- params["cursor"] = cursor
393
-
394
- result = await self._send_request("thread/list", params)
395
-
396
- threads = []
397
- for item in result.get("data", []):
398
- threads.append(
399
- CodexThread(
400
- id=item.get("id", ""),
401
- preview=item.get("preview", ""),
402
- model_provider=item.get("modelProvider", "openai"),
403
- created_at=item.get("createdAt", 0),
404
- )
405
- )
406
-
407
- next_cursor = result.get("nextCursor")
408
- return threads, next_cursor
409
-
410
- async def archive_thread(self, thread_id: str) -> None:
411
- """
412
- Archive a Codex thread.
413
-
414
- Args:
415
- thread_id: ID of the thread to archive
416
- """
417
- await self._send_request("thread/archive", {"threadId": thread_id})
418
- self._threads.pop(thread_id, None)
419
- logger.debug(f"Archived Codex thread: {thread_id}")
420
-
421
- # ===== Turn Management =====
422
-
423
- async def start_turn(
424
- self,
425
- thread_id: str,
426
- prompt: str,
427
- images: list[str] | None = None,
428
- **config_overrides: Any,
429
- ) -> CodexTurn:
430
- """
431
- Start a new turn (send user input and trigger generation).
432
-
433
- Args:
434
- thread_id: Thread ID to add turn to
435
- prompt: User's input text
436
- images: Optional list of image paths or URLs
437
- **config_overrides: Optional config overrides (cwd, model, etc.)
438
-
439
- Returns:
440
- CodexTurn object (initial state, updates via notifications)
441
- """
442
- # Build input array
443
- inputs: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
444
-
445
- if images:
446
- for img in images:
447
- if img.startswith(("http://", "https://")):
448
- inputs.append({"type": "image", "url": img})
449
- else:
450
- inputs.append({"type": "localImage", "path": img})
451
-
452
- params: dict[str, Any] = {
453
- "threadId": thread_id,
454
- "input": inputs,
455
- }
456
- params.update(config_overrides)
457
-
458
- result = await self._send_request("turn/start", params)
459
-
460
- turn_data = result.get("turn", {})
461
- turn = CodexTurn(
462
- id=turn_data.get("id", ""),
463
- thread_id=thread_id,
464
- status=turn_data.get("status", "inProgress"),
465
- items=turn_data.get("items", []),
466
- error=turn_data.get("error"),
467
- )
468
-
469
- logger.debug(f"Started turn {turn.id} in thread {thread_id}")
470
- return turn
471
-
472
- async def interrupt_turn(self, thread_id: str, turn_id: str) -> None:
473
- """
474
- Interrupt an in-progress turn.
475
-
476
- Args:
477
- thread_id: Thread ID containing the turn
478
- turn_id: Turn ID to interrupt
479
- """
480
- await self._send_request("turn/interrupt", {"threadId": thread_id, "turnId": turn_id})
481
- logger.debug(f"Interrupted turn {turn_id}")
482
-
483
- async def run_turn(
484
- self,
485
- thread_id: str,
486
- prompt: str,
487
- images: list[str] | None = None,
488
- **config_overrides: Any,
489
- ) -> AsyncIterator[dict[str, Any]]:
490
- """
491
- Run a turn and yield streaming events.
492
-
493
- This is the primary method for interacting with Codex. It starts a turn
494
- and yields all events until completion.
495
-
496
- Args:
497
- thread_id: Thread ID
498
- prompt: User's input text
499
- images: Optional image paths/URLs
500
- **config_overrides: Config overrides
501
-
502
- Yields:
503
- Event dicts with "type" and event-specific data
504
-
505
- Example:
506
- async for event in client.run_turn(thread.id, "Help me refactor"):
507
- if event["type"] == "item.completed":
508
- print(event["item"]["text"])
509
- """
510
- # Queue to receive notifications
511
- event_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
512
- turn_completed = asyncio.Event()
513
-
514
- def on_event(method: str, params: dict[str, Any]) -> None:
515
- event_queue.put_nowait({"type": method, **params})
516
- if method == "turn/completed":
517
- turn_completed.set()
518
-
519
- # Register handlers for all turn-related events
520
- event_methods = [
521
- "turn/started",
522
- "turn/completed",
523
- "item/started",
524
- "item/completed",
525
- "item/agentMessage/delta",
526
- ]
527
-
528
- for method in event_methods:
529
- self.add_notification_handler(method, on_event)
530
-
531
- try:
532
- # Start the turn
533
- turn = await self.start_turn(thread_id, prompt, images=images, **config_overrides)
534
-
535
- yield {"type": "turn/created", "turn": turn.__dict__}
536
-
537
- # Yield events until turn completes
538
- while not turn_completed.is_set():
539
- try:
540
- event = await asyncio.wait_for(event_queue.get(), timeout=0.1)
541
- yield event
542
- except TimeoutError:
543
- continue
544
-
545
- # Drain remaining events
546
- while not event_queue.empty():
547
- yield event_queue.get_nowait()
548
-
549
- finally:
550
- # Unregister handlers
551
- for method in event_methods:
552
- self.remove_notification_handler(method, on_event)
553
-
554
- # ===== Authentication =====
555
-
556
- async def login_with_api_key(self, api_key: str) -> dict[str, Any]:
557
- """
558
- Authenticate using an OpenAI API key.
559
-
560
- Args:
561
- api_key: OpenAI API key (sk-...)
562
-
563
- Returns:
564
- Login result dict
565
- """
566
- result = await self._send_request(
567
- "account/login/start", {"type": "apiKey", "apiKey": api_key}
568
- )
569
- logger.debug("Logged in with API key")
570
- return result
571
-
572
- async def get_account_status(self) -> dict[str, Any]:
573
- """Get current account/authentication status."""
574
- return await self._send_request("account/status", {})
575
-
576
- # ===== Internal Methods =====
577
-
578
- def _next_request_id(self) -> int:
579
- """Generate unique request ID."""
580
- with self._request_id_lock:
581
- self._request_id += 1
582
- return self._request_id
583
-
584
- async def _send_request(
585
- self, method: str, params: dict[str, Any], timeout: float = 60.0
586
- ) -> dict[str, Any]:
587
- """
588
- Send a JSON-RPC request and wait for response.
589
-
590
- Args:
591
- method: RPC method name
592
- params: Method parameters
593
- timeout: Response timeout in seconds
594
-
595
- Returns:
596
- Result dict from response
597
-
598
- Raises:
599
- RuntimeError: If not connected or request fails
600
- TimeoutError: If response times out
601
- """
602
- if not self._process or not self._process.stdin:
603
- raise RuntimeError("Not connected to Codex app-server")
604
-
605
- request_id = self._next_request_id()
606
- request = {
607
- "jsonrpc": "2.0",
608
- "method": method,
609
- "id": request_id,
610
- "params": params,
611
- }
612
-
613
- # Create future for response
614
- loop = asyncio.get_event_loop()
615
- future: asyncio.Future[Any] = loop.create_future()
616
-
617
- with self._pending_requests_lock:
618
- self._pending_requests[request_id] = future
619
-
620
- try:
621
- # Send request
622
- request_line = json.dumps(request) + "\n"
623
- self._process.stdin.write(request_line)
624
- self._process.stdin.flush()
625
-
626
- logger.debug(f"Sent request: {method} (id={request_id})")
627
-
628
- # Wait for response
629
- result = await asyncio.wait_for(future, timeout=timeout)
630
- return cast(dict[str, Any], result)
631
-
632
- except TimeoutError:
633
- logger.error(f"Request {method} (id={request_id}) timed out")
634
- raise
635
- finally:
636
- with self._pending_requests_lock:
637
- self._pending_requests.pop(request_id, None)
638
-
639
- async def _send_notification(self, method: str, params: dict[str, Any]) -> None:
640
- """Send a JSON-RPC notification (no response expected)."""
641
- if not self._process or not self._process.stdin:
642
- raise RuntimeError("Not connected to Codex app-server")
643
-
644
- notification = {"jsonrpc": "2.0", "method": method, "params": params}
645
-
646
- notification_line = json.dumps(notification) + "\n"
647
- self._process.stdin.write(notification_line)
648
- self._process.stdin.flush()
649
-
650
- logger.debug(f"Sent notification: {method}")
651
-
652
- async def _read_loop(self) -> None:
653
- """Background task to read responses and notifications."""
654
- if not self._process or not self._process.stdout:
655
- return
656
-
657
- loop = asyncio.get_event_loop()
658
-
659
- while not self._shutdown_event.is_set():
660
- try:
661
- # Read line in thread pool to avoid blocking
662
- line = await loop.run_in_executor(None, self._process.stdout.readline)
663
-
664
- if not line:
665
- if self._process.poll() is not None:
666
- logger.warning("Codex app-server process terminated")
667
- self._state = CodexConnectionState.ERROR
668
- break
669
- continue
670
-
671
- # Parse JSON-RPC message
672
- try:
673
- message = json.loads(line.strip())
674
- except json.JSONDecodeError as e:
675
- logger.warning(f"Invalid JSON from app-server: {e}")
676
- continue
677
-
678
- # Handle response (has "id")
679
- if "id" in message:
680
- request_id = message["id"]
681
- with self._pending_requests_lock:
682
- future = self._pending_requests.get(request_id)
683
-
684
- if future and not future.done():
685
- if "error" in message:
686
- error = message["error"]
687
- future.set_exception(
688
- RuntimeError(
689
- f"RPC error {error.get('code')}: {error.get('message')}"
690
- )
691
- )
692
- else:
693
- future.set_result(message.get("result", {}))
694
-
695
- # Handle notification (no "id")
696
- elif "method" in message:
697
- method = message["method"]
698
- params = message.get("params", {})
699
-
700
- logger.debug(f"Received notification: {method}")
701
-
702
- # Call global handler
703
- if self._on_notification:
704
- try:
705
- self._on_notification(method, params)
706
- except Exception as e:
707
- logger.error(f"Notification handler error: {e}")
708
-
709
- # Call method-specific handlers
710
- handlers = self._notification_handlers.get(method, [])
711
- for handler in handlers:
712
- try:
713
- handler(method, params)
714
- except Exception as e:
715
- logger.error(f"Handler error for {method}: {e}")
716
-
717
- except asyncio.CancelledError:
718
- break
719
- except Exception as e:
720
- logger.error(f"Error in read loop: {e}", exc_info=True)
721
- if self._shutdown_event.is_set():
722
- break
723
-
724
-
725
- # =============================================================================
726
- # Shared Utilities
727
- # =============================================================================
728
-
729
-
730
- def _get_machine_id() -> str:
731
- """Get or generate a stable machine identifier based on hostname."""
732
- node = platform.node()
733
- if node:
734
- return str(uuid.uuid5(uuid.NAMESPACE_DNS, node))
735
- return str(uuid.uuid4())
736
-
737
-
738
- # =============================================================================
739
- # App-Server Adapter (for programmatic control)
740
- # =============================================================================
741
-
742
-
743
- class CodexAdapter(BaseAdapter):
744
- """Adapter for Codex CLI session tracking via app-server events.
745
-
746
- This adapter translates Codex app-server events to unified HookEvent
747
- for session tracking. It can operate in two modes:
748
-
749
- 1. Integrated mode (recommended): Attach to existing CodexAppServerClient
750
- - Call attach_to_client(codex_client) with the existing client
751
- - Events are forwarded from the client's notification handlers
752
-
753
- 2. Standalone mode: Use without CodexAppServerClient
754
- - Only provides translation methods for events received externally
755
- - No subprocess management (use CodexAppServerClient for that)
756
-
757
- Lifecycle (integrated mode):
758
- - attach_to_client(codex_client) registers notification handlers
759
- - Events processed through HookManager for session registration
760
- - detach_from_client() removes handlers
761
- """
762
-
763
- source = SessionSource.CODEX
764
-
765
- # Event type mapping: Codex app-server methods -> unified HookEventType
766
- EVENT_MAP: dict[str, HookEventType] = {
767
- "thread/started": HookEventType.SESSION_START,
768
- "thread/archive": HookEventType.SESSION_END,
769
- "turn/started": HookEventType.BEFORE_AGENT,
770
- "turn/completed": HookEventType.AFTER_AGENT,
771
- # Approval requests map to BEFORE_TOOL
772
- "item/commandExecution/requestApproval": HookEventType.BEFORE_TOOL,
773
- "item/fileChange/requestApproval": HookEventType.BEFORE_TOOL,
774
- # Completed items map to AFTER_TOOL
775
- "item/completed": HookEventType.AFTER_TOOL,
776
- }
777
-
778
- # Tool name mapping: Codex tool names -> canonical CC-style names
779
- # Codex uses different tool names - normalize to Claude Code conventions
780
- # so block_tools rules work across CLIs
781
- TOOL_MAP: dict[str, str] = {
782
- # File operations
783
- "read_file": "Read",
784
- "ReadFile": "Read",
785
- "write_file": "Write",
786
- "WriteFile": "Write",
787
- "edit_file": "Edit",
788
- "EditFile": "Edit",
789
- # Shell
790
- "run_shell_command": "Bash",
791
- "RunShellCommand": "Bash",
792
- "commandExecution": "Bash",
793
- # Search
794
- "glob": "Glob",
795
- "grep": "Grep",
796
- "GlobTool": "Glob",
797
- "GrepTool": "Grep",
798
- }
799
-
800
- # Item types that represent tool operations
801
- TOOL_ITEM_TYPES = {"commandExecution", "fileChange", "mcpToolCall"}
802
-
803
- # Events we want to listen for session tracking
804
- SESSION_TRACKING_EVENTS = [
805
- "thread/started",
806
- "turn/started",
807
- "turn/completed",
808
- "item/completed",
809
- ]
810
-
811
- def __init__(self, hook_manager: HookManager | None = None):
812
- """Initialize the Codex adapter.
813
-
814
- Args:
815
- hook_manager: Reference to HookManager for event processing.
816
- """
817
- self._hook_manager = hook_manager
818
- self._codex_client: CodexAppServerClient | None = None
819
- self._machine_id: str | None = None
820
- self._attached = False
821
-
822
- @staticmethod
823
- def is_codex_available() -> bool:
824
- """Check if Codex CLI is installed and available.
825
-
826
- Returns:
827
- True if `codex` command is found in PATH.
828
- """
829
- import shutil
830
-
831
- return shutil.which("codex") is not None
832
-
833
- def _get_machine_id(self) -> str:
834
- """Get or generate a machine identifier."""
835
- if self._machine_id is None:
836
- self._machine_id = _get_machine_id()
837
- return self._machine_id
838
-
839
- def normalize_tool_name(self, codex_tool_name: str) -> str:
840
- """Normalize Codex tool name to canonical CC-style format.
841
-
842
- This ensures block_tools rules work consistently across CLIs.
843
-
844
- Args:
845
- codex_tool_name: Tool name from Codex CLI.
846
-
847
- Returns:
848
- Normalized tool name (e.g., "Bash", "Read", "Write", "Edit").
849
- """
850
- return self.TOOL_MAP.get(codex_tool_name, codex_tool_name)
851
-
852
- def attach_to_client(self, codex_client: CodexAppServerClient) -> None:
853
- """Attach to an existing CodexAppServerClient for event handling.
854
-
855
- Registers notification handlers on the client to receive session
856
- tracking events. This is the preferred integration mode.
857
-
858
- Args:
859
- codex_client: The CodexAppServerClient to attach to.
860
- """
861
- if self._attached:
862
- logger.warning("CodexAdapter already attached to a client")
863
- return
864
-
865
- self._codex_client = codex_client
866
-
867
- # Register handlers for session tracking events
868
- for method in self.SESSION_TRACKING_EVENTS:
869
- codex_client.add_notification_handler(method, self._handle_notification)
870
-
871
- self._attached = True
872
- logger.debug("CodexAdapter attached to CodexAppServerClient")
873
-
874
- def detach_from_client(self) -> None:
875
- """Detach from the CodexAppServerClient.
876
-
877
- Removes notification handlers. Call this before disposing the adapter.
878
- """
879
- if not self._attached or not self._codex_client:
880
- return
881
-
882
- # Remove handlers
883
- for method in self.SESSION_TRACKING_EVENTS:
884
- self._codex_client.remove_notification_handler(method, self._handle_notification)
885
-
886
- self._codex_client = None
887
- self._attached = False
888
- logger.debug("CodexAdapter detached from CodexAppServerClient")
889
-
890
- def _handle_notification(self, method: str, params: dict[str, Any]) -> None:
891
- """Handle notification from CodexAppServerClient.
892
-
893
- This is the callback registered with the client for session tracking events.
894
- """
895
- try:
896
- hook_event = self.translate_to_hook_event({"method": method, "params": params})
897
-
898
- if hook_event and self._hook_manager:
899
- # Process through HookManager (fire-and-forget for notifications)
900
- self._hook_manager.handle(hook_event)
901
- logger.debug(f"Processed Codex event: {method} -> {hook_event.event_type}")
902
- except Exception as e:
903
- logger.error(f"Error handling Codex notification {method}: {e}")
904
-
905
- def _translate_approval_event(self, method: str, params: dict[str, Any]) -> HookEvent | None:
906
- """Translate approval request to HookEvent."""
907
- if method not in self.EVENT_MAP:
908
- logger.debug(f"Unknown approval method: {method}")
909
- return None
910
-
911
- thread_id = params.get("threadId", "")
912
- item_id = params.get("itemId", "")
913
-
914
- # Determine tool name from method and normalize to CC-style
915
- if "commandExecution" in method:
916
- original_tool = "commandExecution"
917
- tool_name = self.normalize_tool_name(original_tool) # -> "Bash"
918
- tool_input = params.get("parsedCmd", params.get("command", ""))
919
- elif "fileChange" in method:
920
- original_tool = "fileChange"
921
- tool_name = "Write" # File changes are writes
922
- tool_input = params.get("changes", [])
923
- else:
924
- original_tool = "unknown"
925
- tool_name = "unknown"
926
- tool_input = params
927
-
928
- return HookEvent(
929
- event_type=HookEventType.BEFORE_TOOL,
930
- session_id=thread_id,
931
- source=self.source,
932
- timestamp=datetime.now(UTC),
933
- machine_id=self._get_machine_id(),
934
- data={
935
- "tool_name": tool_name,
936
- "tool_input": tool_input,
937
- "item_id": item_id,
938
- "turn_id": params.get("turnId", ""),
939
- "reason": params.get("reason"),
940
- "risk": params.get("risk"),
941
- },
942
- metadata={
943
- "requires_response": True,
944
- "item_id": item_id,
945
- "approval_method": method,
946
- "original_tool_name": original_tool,
947
- "normalized_tool_name": tool_name,
948
- },
949
- )
950
-
951
- def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent | None:
952
- """Convert Codex app-server event to unified HookEvent.
953
-
954
- Codex events come as JSON-RPC notifications:
955
- {
956
- "method": "thread/started",
957
- "params": {
958
- "thread": {"id": "thr_123", "preview": "...", ...}
959
- }
960
- }
961
-
962
- Args:
963
- native_event: JSON-RPC notification with method and params.
964
-
965
- Returns:
966
- Unified HookEvent, or None for unsupported events.
967
- """
968
- method = native_event.get("method", "")
969
- params = native_event.get("params", {})
970
-
971
- # Handle different event types
972
- if method == "thread/started":
973
- thread = params.get("thread", {})
974
- return HookEvent(
975
- event_type=HookEventType.SESSION_START,
976
- session_id=thread.get("id", ""),
977
- source=self.source,
978
- timestamp=self._parse_timestamp(thread.get("createdAt")),
979
- machine_id=self._get_machine_id(),
980
- data={
981
- "preview": thread.get("preview", ""),
982
- "model_provider": thread.get("modelProvider", ""),
983
- },
984
- )
985
-
986
- if method == "thread/archive":
987
- return HookEvent(
988
- event_type=HookEventType.SESSION_END,
989
- session_id=params.get("threadId", ""),
990
- source=self.source,
991
- timestamp=datetime.now(UTC),
992
- machine_id=self._get_machine_id(),
993
- data=params,
994
- )
995
-
996
- if method == "turn/started":
997
- turn = params.get("turn", {})
998
- return HookEvent(
999
- event_type=HookEventType.BEFORE_AGENT,
1000
- session_id=params.get("threadId", turn.get("id", "")),
1001
- source=self.source,
1002
- timestamp=datetime.now(UTC),
1003
- machine_id=self._get_machine_id(),
1004
- data={
1005
- "turn_id": turn.get("id", ""),
1006
- "status": turn.get("status", ""),
1007
- },
1008
- )
1009
-
1010
- if method == "turn/completed":
1011
- turn = params.get("turn", {})
1012
- return HookEvent(
1013
- event_type=HookEventType.AFTER_AGENT,
1014
- session_id=params.get("threadId", turn.get("id", "")),
1015
- source=self.source,
1016
- timestamp=datetime.now(UTC),
1017
- machine_id=self._get_machine_id(),
1018
- data={
1019
- "turn_id": turn.get("id", ""),
1020
- "status": turn.get("status", ""),
1021
- "error": turn.get("error"),
1022
- },
1023
- )
1024
-
1025
- if method == "item/completed":
1026
- item = params.get("item", {})
1027
- item_type = item.get("type", "")
1028
-
1029
- # Only translate tool-related items
1030
- if item_type in self.TOOL_ITEM_TYPES:
1031
- return HookEvent(
1032
- event_type=HookEventType.AFTER_TOOL,
1033
- session_id=params.get("threadId", ""),
1034
- source=self.source,
1035
- timestamp=datetime.now(UTC),
1036
- machine_id=self._get_machine_id(),
1037
- data={
1038
- "item_id": item.get("id", ""),
1039
- "item_type": item_type,
1040
- "status": item.get("status", ""),
1041
- },
1042
- )
1043
-
1044
- # Unknown/unsupported event
1045
- logger.debug(f"Unsupported Codex event: {method}")
1046
- return None
1047
-
1048
- def translate_from_hook_response(
1049
- self, response: HookResponse, hook_type: str | None = None
1050
- ) -> dict[str, Any]:
1051
- """Convert HookResponse to Codex approval response format.
1052
-
1053
- Codex expects approval responses as:
1054
- {
1055
- "decision": "accept" | "decline"
1056
- }
1057
-
1058
- Args:
1059
- response: Unified HookResponse.
1060
- hook_type: Original Codex method (unused, kept for interface).
1061
-
1062
- Returns:
1063
- Dict with decision field.
1064
- """
1065
- return {
1066
- "decision": "accept" if response.decision != "deny" else "decline",
1067
- }
1068
-
1069
- def _parse_timestamp(self, unix_ts: int | float | None) -> datetime:
1070
- """Parse Unix timestamp to datetime.
1071
-
1072
- Args:
1073
- unix_ts: Unix timestamp (seconds).
1074
-
1075
- Returns:
1076
- datetime object, or now() if parsing fails.
1077
- """
1078
- if unix_ts:
1079
- try:
1080
- return datetime.fromtimestamp(unix_ts)
1081
- except (ValueError, OSError):
1082
- pass
1083
- return datetime.now(UTC)
1084
-
1085
- async def sync_existing_sessions(self) -> int:
1086
- """Sync existing Codex threads to platform sessions.
1087
-
1088
- Uses the attached CodexAppServerClient to list threads and registers
1089
- them as sessions via HookManager.
1090
-
1091
- Requires:
1092
- - CodexAdapter attached to a CodexAppServerClient
1093
- - CodexAppServerClient is connected
1094
- - HookManager is set
1095
-
1096
- Returns:
1097
- Number of threads synced.
1098
- """
1099
- if not self._hook_manager:
1100
- logger.warning("No hook_manager - cannot sync sessions")
1101
- return 0
1102
-
1103
- if not self._codex_client:
1104
- logger.warning("No CodexAppServerClient attached - cannot sync sessions")
1105
- return 0
1106
-
1107
- if not self._codex_client.is_connected:
1108
- logger.warning("CodexAppServerClient not connected - cannot sync sessions")
1109
- return 0
1110
-
1111
- try:
1112
- # Use CodexAppServerClient to list threads
1113
- all_threads = []
1114
- cursor = None
1115
-
1116
- while True:
1117
- threads, next_cursor = await self._codex_client.list_threads(
1118
- cursor=cursor, limit=100
1119
- )
1120
- all_threads.extend(threads)
1121
-
1122
- if not next_cursor:
1123
- break
1124
- cursor = next_cursor
1125
-
1126
- synced = 0
1127
- for thread in all_threads:
1128
- try:
1129
- event = HookEvent(
1130
- event_type=HookEventType.SESSION_START,
1131
- session_id=thread.id,
1132
- source=self.source,
1133
- timestamp=self._parse_timestamp(thread.created_at),
1134
- machine_id=self._get_machine_id(),
1135
- data={
1136
- "preview": thread.preview,
1137
- "model_provider": thread.model_provider,
1138
- "synced_from_existing": True,
1139
- },
1140
- )
1141
- self._hook_manager.handle(event)
1142
- synced += 1
1143
- except Exception as e:
1144
- logger.error(f"Failed to sync thread {thread.id}: {e}")
1145
-
1146
- logger.debug(f"Synced {synced} existing Codex threads")
1147
- return synced
1148
-
1149
- except Exception as e:
1150
- logger.error(f"Failed to sync existing sessions: {e}")
1151
- return 0
1152
-
1153
-
1154
- # =============================================================================
1155
- # Notify Adapter (for installed hooks via `gobby install --codex`)
1156
- # =============================================================================
1157
-
1158
-
1159
- class CodexNotifyAdapter(BaseAdapter):
1160
- """Adapter for Codex CLI notify events.
1161
-
1162
- Translates notify payloads to unified HookEvent format.
1163
- The notify hook only fires on `agent-turn-complete`, so we:
1164
- - Treat first event for a thread as session start + prompt submit
1165
- - Track thread IDs to avoid duplicate session registration
1166
-
1167
- This adapter handles events from the hook_dispatcher.py script installed
1168
- by `gobby install --codex`.
1169
- """
1170
-
1171
- source = SessionSource.CODEX
1172
-
1173
- def __init__(self, hook_manager: HookManager | None = None):
1174
- """Initialize the adapter.
1175
-
1176
- Args:
1177
- hook_manager: Optional HookManager reference.
1178
- """
1179
- self._hook_manager = hook_manager
1180
- self._machine_id: str | None = None
1181
- # Track threads we've seen to avoid re-registering
1182
- self._seen_threads: set[str] = set()
1183
-
1184
- def _get_machine_id(self) -> str:
1185
- """Get or generate a machine identifier."""
1186
- if self._machine_id is None:
1187
- self._machine_id = _get_machine_id()
1188
- return self._machine_id
1189
-
1190
- def _find_jsonl_path(self, thread_id: str) -> str | None:
1191
- """Find the Codex session JSONL file for a thread.
1192
-
1193
- Codex stores sessions at: ~/.codex/sessions/YYYY/MM/DD/rollout-{timestamp}-{thread-id}.jsonl
1194
-
1195
- Args:
1196
- thread_id: The Codex thread ID
1197
-
1198
- Returns:
1199
- Path to the JSONL file, or None if not found
1200
- """
1201
- if not CODEX_SESSIONS_DIR.exists():
1202
- return None
1203
-
1204
- # Search for file ending with thread-id.jsonl
1205
- # Escape special glob characters in thread_id
1206
- safe_thread_id = glob_module.escape(thread_id)
1207
- pattern = str(CODEX_SESSIONS_DIR / "**" / f"*{safe_thread_id}.jsonl")
1208
- matches = glob_module.glob(pattern, recursive=True)
1209
-
1210
- if matches:
1211
- # Return the most recent match (in case of duplicates)
1212
- return max(matches, key=os.path.getmtime)
1213
- return None
1214
-
1215
- def _get_first_prompt(self, input_messages: list[Any]) -> str | None:
1216
- """Extract the first user prompt from input_messages.
1217
-
1218
- Args:
1219
- input_messages: List of user messages from Codex
1220
-
1221
- Returns:
1222
- First prompt string, or None
1223
- """
1224
- if input_messages and isinstance(input_messages, list) and len(input_messages) > 0:
1225
- first = input_messages[0]
1226
- if isinstance(first, str):
1227
- return first
1228
- elif isinstance(first, dict):
1229
- return first.get("text") or first.get("content")
1230
- return None
1231
-
1232
- def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent | None:
1233
- """Convert Codex notify payload to HookEvent.
1234
-
1235
- The native_event structure from /hooks/execute:
1236
- {
1237
- "hook_type": "AgentTurnComplete",
1238
- "input_data": {
1239
- "session_id": "thread-id",
1240
- "event_type": "agent-turn-complete",
1241
- "last_message": "...",
1242
- "input_messages": [...],
1243
- "cwd": "/path/to/project",
1244
- "turn_id": "1"
1245
- },
1246
- "source": "codex"
1247
- }
1248
-
1249
- Args:
1250
- native_event: The payload from the HTTP endpoint.
1251
-
1252
- Returns:
1253
- HookEvent for processing, or None if unsupported.
1254
- """
1255
- input_data = native_event.get("input_data", {})
1256
- thread_id = input_data.get("session_id", "")
1257
- event_type = input_data.get("event_type", "unknown")
1258
- input_messages = input_data.get("input_messages", [])
1259
- cwd = input_data.get("cwd") or os.getcwd()
1260
-
1261
- if not thread_id:
1262
- logger.warning("Codex notify event missing thread_id")
1263
- return None
1264
-
1265
- # Find the JSONL transcript file
1266
- jsonl_path = self._find_jsonl_path(thread_id)
1267
-
1268
- # Track if this is the first event for this thread (for title synthesis)
1269
- is_first_event = thread_id not in self._seen_threads
1270
- if is_first_event:
1271
- self._seen_threads.add(thread_id)
1272
-
1273
- # Get first prompt for title synthesis (only on first event)
1274
- first_prompt = self._get_first_prompt(input_messages) if is_first_event else None
1275
-
1276
- # All Codex notify events are AFTER_AGENT (turn complete)
1277
- # The HookManager will auto-register the session if it doesn't exist
1278
- return HookEvent(
1279
- event_type=HookEventType.AFTER_AGENT,
1280
- session_id=thread_id,
1281
- source=self.source,
1282
- timestamp=datetime.now(UTC),
1283
- machine_id=self._get_machine_id(),
1284
- data={
1285
- "cwd": cwd,
1286
- "event_type": event_type,
1287
- "last_message": input_data.get("last_message", ""),
1288
- "input_messages": input_messages,
1289
- "transcript_path": jsonl_path,
1290
- "is_first_event": is_first_event,
1291
- "prompt": first_prompt, # For title synthesis on first event
1292
- },
1293
- )
1294
-
1295
- def translate_from_hook_response(
1296
- self, response: HookResponse, hook_type: str | None = None
1297
- ) -> dict[str, Any]:
1298
- """Convert HookResponse to Codex-expected format.
1299
-
1300
- Codex notify doesn't expect a response - it's fire-and-forget.
1301
- This just returns a simple status dict for logging.
1302
-
1303
- Args:
1304
- response: The HookResponse from HookManager.
1305
- hook_type: Ignored (notify doesn't need response routing).
1306
-
1307
- Returns:
1308
- Simple status dict.
1309
- """
1310
- return {
1311
- "status": "processed",
1312
- "decision": response.decision,
1313
- }
1314
-
1315
- def handle_native(
1316
- self, native_event: dict[str, Any], hook_manager: HookManager
1317
- ) -> dict[str, Any]:
1318
- """Process native Codex notify event.
1319
-
1320
- Args:
1321
- native_event: The payload from HTTP endpoint.
1322
- hook_manager: HookManager instance for processing.
1323
-
1324
- Returns:
1325
- Response dict.
1326
- """
1327
- hook_event = self.translate_to_hook_event(native_event)
1328
- if not hook_event:
1329
- return {"status": "skipped", "message": "Unsupported event"}
1330
-
1331
- hook_response = hook_manager.handle(hook_event)
1332
- return self.translate_from_hook_response(hook_response)