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,722 @@
1
+ """
2
+ Codex adapter implementations.
3
+
4
+ Contains the main adapter classes for Codex CLI integration:
5
+ - CodexAdapter: Main adapter for app-server mode (programmatic control)
6
+ - CodexNotifyAdapter: Notification adapter for hook events
7
+
8
+ Extracted from codex.py as part of Phase 3 Strangler Fig decomposition.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import glob as glob_module
14
+ import logging
15
+ import os
16
+ import platform
17
+ import uuid
18
+ from collections import OrderedDict
19
+ from datetime import UTC, datetime
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from gobby.adapters.base import BaseAdapter
23
+ from gobby.adapters.codex_impl.client import (
24
+ CODEX_SESSIONS_DIR,
25
+ CodexAppServerClient,
26
+ )
27
+ from gobby.adapters.codex_impl.types import (
28
+ CodexThread,
29
+ )
30
+ from gobby.hooks.events import HookEvent, HookEventType, HookResponse, SessionSource
31
+
32
+ if TYPE_CHECKING:
33
+ from gobby.hooks.hook_manager import HookManager
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # =============================================================================
39
+ # Shared Utilities
40
+ # =============================================================================
41
+
42
+
43
+ def _get_machine_id() -> str:
44
+ """Get or generate a stable machine identifier.
45
+
46
+ Priority:
47
+ 1. Hostname (if available)
48
+ 2. MAC address (if real, not random)
49
+ 3. Persisted UUID file (created on first run)
50
+ """
51
+ from pathlib import Path
52
+
53
+ # Try hostname first
54
+ node = platform.node()
55
+ if node:
56
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, node))
57
+
58
+ # Try MAC address - getnode() returns random value with multicast bit set if unavailable
59
+ mac = uuid.getnode()
60
+ # Check if MAC is real (multicast bit / bit 0 of first octet is 0)
61
+ if not (mac >> 40) & 1:
62
+ return str(uuid.uuid5(uuid.NAMESPACE_DNS, str(mac)))
63
+
64
+ # Fall back to persisted ID file for stability across restarts
65
+ machine_id_file = Path.home() / ".gobby" / ".machine_id"
66
+ try:
67
+ if machine_id_file.exists():
68
+ stored_id = machine_id_file.read_text().strip()
69
+ if stored_id:
70
+ return stored_id
71
+ except OSError:
72
+ pass # Fall through to generate new ID
73
+
74
+ # Generate and persist a new ID
75
+ new_id = str(uuid.uuid4())
76
+ try:
77
+ machine_id_file.parent.mkdir(parents=True, exist_ok=True)
78
+ machine_id_file.write_text(new_id)
79
+ except OSError:
80
+ pass # Use the generated ID even if we can't persist it
81
+
82
+ return new_id
83
+
84
+
85
+ # =============================================================================
86
+ # App-Server Adapter (for programmatic control)
87
+ # =============================================================================
88
+
89
+
90
+ class CodexAdapter(BaseAdapter):
91
+ """Adapter for Codex CLI session tracking via app-server events.
92
+
93
+ This adapter translates Codex app-server events to unified HookEvent
94
+ for session tracking. It can operate in two modes:
95
+
96
+ 1. Integrated mode (recommended): Attach to existing CodexAppServerClient
97
+ - Call attach_to_client(codex_client) with the existing client
98
+ - Events are forwarded from the client's notification handlers
99
+
100
+ 2. Standalone mode: Use without CodexAppServerClient
101
+ - Only provides translation methods for events received externally
102
+ - No subprocess management (use CodexAppServerClient for that)
103
+
104
+ Lifecycle (integrated mode):
105
+ - attach_to_client(codex_client) registers notification handlers
106
+ - Events processed through HookManager for session registration
107
+ - detach_from_client() removes handlers
108
+ """
109
+
110
+ source = SessionSource.CODEX
111
+
112
+ # Event type mapping: Codex app-server methods -> unified HookEventType
113
+ EVENT_MAP: dict[str, HookEventType] = {
114
+ "thread/started": HookEventType.SESSION_START,
115
+ "thread/archive": HookEventType.SESSION_END,
116
+ "turn/started": HookEventType.BEFORE_AGENT,
117
+ "turn/completed": HookEventType.AFTER_AGENT,
118
+ # Approval requests map to BEFORE_TOOL
119
+ "item/commandExecution/requestApproval": HookEventType.BEFORE_TOOL,
120
+ "item/fileChange/requestApproval": HookEventType.BEFORE_TOOL,
121
+ # Completed items map to AFTER_TOOL
122
+ "item/completed": HookEventType.AFTER_TOOL,
123
+ }
124
+
125
+ # Tool name mapping: Codex tool names -> canonical CC-style names
126
+ # Codex uses different tool names - normalize to Claude Code conventions
127
+ # so block_tools rules work across CLIs
128
+ TOOL_MAP: dict[str, str] = {
129
+ # File operations
130
+ "read_file": "Read",
131
+ "ReadFile": "Read",
132
+ "write_file": "Write",
133
+ "WriteFile": "Write",
134
+ "edit_file": "Edit",
135
+ "EditFile": "Edit",
136
+ # Shell
137
+ "run_shell_command": "Bash",
138
+ "RunShellCommand": "Bash",
139
+ "commandExecution": "Bash",
140
+ # Search
141
+ "glob": "Glob",
142
+ "grep": "Grep",
143
+ "GlobTool": "Glob",
144
+ "GrepTool": "Grep",
145
+ }
146
+
147
+ # Item types that represent tool operations
148
+ TOOL_ITEM_TYPES = {"commandExecution", "fileChange", "mcpToolCall"}
149
+
150
+ # Events we want to listen for session tracking
151
+ SESSION_TRACKING_EVENTS = [
152
+ "thread/started",
153
+ "turn/started",
154
+ "turn/completed",
155
+ "item/completed",
156
+ ]
157
+
158
+ def __init__(self, hook_manager: HookManager | None = None):
159
+ """Initialize the Codex adapter.
160
+
161
+ Args:
162
+ hook_manager: Reference to HookManager for event processing.
163
+ """
164
+ self._hook_manager = hook_manager
165
+ self._codex_client: CodexAppServerClient | None = None
166
+ self._machine_id: str | None = None
167
+ self._attached = False
168
+
169
+ @staticmethod
170
+ def is_codex_available() -> bool:
171
+ """Check if Codex CLI is installed and available.
172
+
173
+ Returns:
174
+ True if `codex` command is found in PATH.
175
+ """
176
+ import shutil
177
+
178
+ return shutil.which("codex") is not None
179
+
180
+ def _get_machine_id(self) -> str:
181
+ """Get or generate a machine identifier."""
182
+ if self._machine_id is None:
183
+ self._machine_id = _get_machine_id()
184
+ return self._machine_id
185
+
186
+ def normalize_tool_name(self, codex_tool_name: str) -> str:
187
+ """Normalize Codex tool name to canonical CC-style format.
188
+
189
+ This ensures block_tools rules work consistently across CLIs.
190
+
191
+ Args:
192
+ codex_tool_name: Tool name from Codex CLI.
193
+
194
+ Returns:
195
+ Normalized tool name (e.g., "Bash", "Read", "Write", "Edit").
196
+ """
197
+ return self.TOOL_MAP.get(codex_tool_name, codex_tool_name)
198
+
199
+ def attach_to_client(self, codex_client: CodexAppServerClient) -> None:
200
+ """Attach to an existing CodexAppServerClient for event handling.
201
+
202
+ Registers notification handlers on the client to receive session
203
+ tracking events. This is the preferred integration mode.
204
+
205
+ Args:
206
+ codex_client: The CodexAppServerClient to attach to.
207
+ """
208
+ if self._attached:
209
+ logger.warning("CodexAdapter already attached to a client")
210
+ return
211
+
212
+ self._codex_client = codex_client
213
+
214
+ # Register handlers for session tracking events
215
+ for method in self.SESSION_TRACKING_EVENTS:
216
+ codex_client.add_notification_handler(method, self._handle_notification)
217
+
218
+ self._attached = True
219
+ logger.debug("CodexAdapter attached to CodexAppServerClient")
220
+
221
+ def detach_from_client(self) -> None:
222
+ """Detach from the CodexAppServerClient.
223
+
224
+ Removes notification handlers. Call this before disposing the adapter.
225
+ """
226
+ if not self._attached or not self._codex_client:
227
+ return
228
+
229
+ # Remove handlers
230
+ for method in self.SESSION_TRACKING_EVENTS:
231
+ self._codex_client.remove_notification_handler(method, self._handle_notification)
232
+
233
+ self._codex_client = None
234
+ self._attached = False
235
+ logger.debug("CodexAdapter detached from CodexAppServerClient")
236
+
237
+ def _handle_notification(self, method: str, params: dict[str, Any]) -> None:
238
+ """Handle notification from CodexAppServerClient.
239
+
240
+ This is the callback registered with the client for session tracking events.
241
+ """
242
+ try:
243
+ hook_event = self.translate_to_hook_event({"method": method, "params": params})
244
+
245
+ if hook_event and self._hook_manager:
246
+ # Process through HookManager (fire-and-forget for notifications)
247
+ self._hook_manager.handle(hook_event)
248
+ logger.debug(f"Processed Codex event: {method} -> {hook_event.event_type}")
249
+ except Exception as e:
250
+ logger.error(f"Error handling Codex notification {method}: {e}")
251
+
252
+ def _translate_approval_event(self, method: str, params: dict[str, Any]) -> HookEvent | None:
253
+ """Translate approval request to HookEvent."""
254
+ if method not in self.EVENT_MAP:
255
+ logger.debug(f"Unknown approval method: {method}")
256
+ return None
257
+
258
+ thread_id = params.get("threadId", "")
259
+ item_id = params.get("itemId", "")
260
+
261
+ # Determine tool name from method and normalize to CC-style
262
+ if "commandExecution" in method:
263
+ original_tool = "commandExecution"
264
+ tool_name = self.normalize_tool_name(original_tool) # -> "Bash"
265
+ tool_input = params.get("parsedCmd", params.get("command", ""))
266
+ elif "fileChange" in method:
267
+ original_tool = "fileChange"
268
+ tool_name = "Write" # File changes are writes
269
+ tool_input = params.get("changes", [])
270
+ else:
271
+ original_tool = "unknown"
272
+ tool_name = "unknown"
273
+ tool_input = params
274
+
275
+ return HookEvent(
276
+ event_type=HookEventType.BEFORE_TOOL,
277
+ session_id=thread_id,
278
+ source=self.source,
279
+ timestamp=datetime.now(UTC),
280
+ machine_id=self._get_machine_id(),
281
+ data={
282
+ "tool_name": tool_name,
283
+ "tool_input": tool_input,
284
+ "item_id": item_id,
285
+ "turn_id": params.get("turnId", ""),
286
+ "reason": params.get("reason"),
287
+ "risk": params.get("risk"),
288
+ },
289
+ metadata={
290
+ "requires_response": True,
291
+ "item_id": item_id,
292
+ "approval_method": method,
293
+ "original_tool_name": original_tool,
294
+ "normalized_tool_name": tool_name,
295
+ },
296
+ )
297
+
298
+ def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent | None:
299
+ """Convert Codex app-server event to unified HookEvent.
300
+
301
+ Codex events come as JSON-RPC notifications:
302
+ {
303
+ "method": "thread/started",
304
+ "params": {
305
+ "thread": {"id": "thr_123", "preview": "...", ...}
306
+ }
307
+ }
308
+
309
+ Args:
310
+ native_event: JSON-RPC notification with method and params.
311
+
312
+ Returns:
313
+ Unified HookEvent, or None for unsupported events.
314
+ """
315
+ method = native_event.get("method", "")
316
+ params = native_event.get("params", {})
317
+
318
+ # Handle different event types
319
+ if method == "thread/started":
320
+ thread = params.get("thread", {})
321
+ return HookEvent(
322
+ event_type=HookEventType.SESSION_START,
323
+ session_id=thread.get("id", ""),
324
+ source=self.source,
325
+ timestamp=self._parse_timestamp(thread.get("createdAt")),
326
+ machine_id=self._get_machine_id(),
327
+ data={
328
+ "preview": thread.get("preview", ""),
329
+ "model_provider": thread.get("modelProvider", ""),
330
+ },
331
+ )
332
+
333
+ if method == "thread/archive":
334
+ return HookEvent(
335
+ event_type=HookEventType.SESSION_END,
336
+ session_id=params.get("threadId", ""),
337
+ source=self.source,
338
+ timestamp=datetime.now(UTC),
339
+ machine_id=self._get_machine_id(),
340
+ data=params,
341
+ )
342
+
343
+ if method == "turn/started":
344
+ turn = params.get("turn", {})
345
+ return HookEvent(
346
+ event_type=HookEventType.BEFORE_AGENT,
347
+ session_id=params.get("threadId", turn.get("id", "")),
348
+ source=self.source,
349
+ timestamp=datetime.now(UTC),
350
+ machine_id=self._get_machine_id(),
351
+ data={
352
+ "turn_id": turn.get("id", ""),
353
+ "status": turn.get("status", ""),
354
+ },
355
+ )
356
+
357
+ if method == "turn/completed":
358
+ turn = params.get("turn", {})
359
+ return HookEvent(
360
+ event_type=HookEventType.AFTER_AGENT,
361
+ session_id=params.get("threadId", turn.get("id", "")),
362
+ source=self.source,
363
+ timestamp=datetime.now(UTC),
364
+ machine_id=self._get_machine_id(),
365
+ data={
366
+ "turn_id": turn.get("id", ""),
367
+ "status": turn.get("status", ""),
368
+ "error": turn.get("error"),
369
+ },
370
+ )
371
+
372
+ if method == "item/completed":
373
+ item = params.get("item", {})
374
+ item_type = item.get("type", "")
375
+
376
+ # Only translate tool-related items
377
+ if item_type in self.TOOL_ITEM_TYPES:
378
+ return HookEvent(
379
+ event_type=HookEventType.AFTER_TOOL,
380
+ session_id=params.get("threadId", ""),
381
+ source=self.source,
382
+ timestamp=datetime.now(UTC),
383
+ machine_id=self._get_machine_id(),
384
+ data={
385
+ "item_id": item.get("id", ""),
386
+ "item_type": item_type,
387
+ "status": item.get("status", ""),
388
+ },
389
+ )
390
+
391
+ # Unknown/unsupported event
392
+ logger.debug(f"Unsupported Codex event: {method}")
393
+ return None
394
+
395
+ def translate_from_hook_response(
396
+ self, response: HookResponse, hook_type: str | None = None
397
+ ) -> dict[str, Any]:
398
+ """Convert HookResponse to Codex approval response format.
399
+
400
+ Codex expects approval responses as:
401
+ {
402
+ "decision": "accept" | "decline"
403
+ }
404
+
405
+ Args:
406
+ response: Unified HookResponse.
407
+ hook_type: Original Codex method (unused, kept for interface).
408
+
409
+ Returns:
410
+ Dict with decision field.
411
+ """
412
+ return {
413
+ "decision": "accept" if response.decision != "deny" else "decline",
414
+ }
415
+
416
+ def _parse_timestamp(self, unix_ts: int | float | None) -> datetime:
417
+ """Parse Unix timestamp to datetime.
418
+
419
+ Args:
420
+ unix_ts: Unix timestamp (seconds).
421
+
422
+ Returns:
423
+ Timezone-aware datetime object, or now(UTC) if parsing fails.
424
+ """
425
+ if unix_ts:
426
+ try:
427
+ return datetime.fromtimestamp(unix_ts, tz=UTC)
428
+ except (ValueError, OSError):
429
+ pass
430
+ return datetime.now(UTC)
431
+
432
+ async def sync_existing_sessions(self) -> int:
433
+ """Sync existing Codex threads to platform sessions.
434
+
435
+ Uses the attached CodexAppServerClient to list threads and registers
436
+ them as sessions via HookManager.
437
+
438
+ Requires:
439
+ - CodexAdapter attached to a CodexAppServerClient
440
+ - CodexAppServerClient is connected
441
+ - HookManager is set
442
+
443
+ Returns:
444
+ Number of threads synced.
445
+ """
446
+ if not self._hook_manager:
447
+ logger.warning("No hook_manager - cannot sync sessions")
448
+ return 0
449
+
450
+ if not self._codex_client:
451
+ logger.warning("No CodexAppServerClient attached - cannot sync sessions")
452
+ return 0
453
+
454
+ if not self._codex_client.is_connected:
455
+ logger.warning("CodexAppServerClient not connected - cannot sync sessions")
456
+ return 0
457
+
458
+ try:
459
+ # Use CodexAppServerClient to list threads
460
+ all_threads: list[CodexThread] = []
461
+ cursor = None
462
+
463
+ while True:
464
+ threads, next_cursor = await self._codex_client.list_threads(
465
+ cursor=cursor, limit=100
466
+ )
467
+ all_threads.extend(threads)
468
+
469
+ if not next_cursor:
470
+ break
471
+ cursor = next_cursor
472
+
473
+ synced = 0
474
+ for thread in all_threads:
475
+ try:
476
+ event = HookEvent(
477
+ event_type=HookEventType.SESSION_START,
478
+ session_id=thread.id,
479
+ source=self.source,
480
+ timestamp=self._parse_timestamp(thread.created_at),
481
+ machine_id=self._get_machine_id(),
482
+ data={
483
+ "preview": thread.preview,
484
+ "model_provider": thread.model_provider,
485
+ "synced_from_existing": True,
486
+ },
487
+ )
488
+ self._hook_manager.handle(event)
489
+ synced += 1
490
+ except Exception as e:
491
+ logger.error(f"Failed to sync thread {thread.id}: {e}")
492
+
493
+ logger.debug(f"Synced {synced} existing Codex threads")
494
+ return synced
495
+
496
+ except Exception as e:
497
+ logger.error(f"Failed to sync existing sessions: {e}")
498
+ return 0
499
+
500
+
501
+ # =============================================================================
502
+ # Notify Adapter (for installed hooks via `gobby install --codex`)
503
+ # =============================================================================
504
+
505
+
506
+ class CodexNotifyAdapter(BaseAdapter):
507
+ """Adapter for Codex CLI notify events.
508
+
509
+ Translates notify payloads to unified HookEvent format.
510
+ The notify hook only fires on `agent-turn-complete`, so we:
511
+ - Treat first event for a thread as session start + prompt submit
512
+ - Track thread IDs to avoid duplicate session registration
513
+
514
+ This adapter handles events from the hook_dispatcher.py script installed
515
+ by `gobby install --codex`.
516
+ """
517
+
518
+ source = SessionSource.CODEX
519
+
520
+ # Default max size for seen threads cache
521
+ DEFAULT_MAX_SEEN_THREADS = 1000
522
+
523
+ def __init__(
524
+ self,
525
+ hook_manager: HookManager | None = None,
526
+ max_seen_threads: int | None = None,
527
+ ):
528
+ """Initialize the adapter.
529
+
530
+ Args:
531
+ hook_manager: Optional HookManager reference.
532
+ max_seen_threads: Max threads to track (default 1000). Oldest evicted when full.
533
+ """
534
+ self._hook_manager = hook_manager
535
+ self._machine_id: str | None = None
536
+ # Track threads we've seen using LRU cache to avoid unbounded growth
537
+ self._max_seen_threads = max_seen_threads or self.DEFAULT_MAX_SEEN_THREADS
538
+ self._seen_threads: OrderedDict[str, bool] = OrderedDict()
539
+
540
+ def _get_machine_id(self) -> str:
541
+ """Get or generate a machine identifier."""
542
+ if self._machine_id is None:
543
+ self._machine_id = _get_machine_id()
544
+ return self._machine_id
545
+
546
+ def _mark_thread_seen(self, thread_id: str) -> None:
547
+ """Mark a thread as seen, evicting oldest if cache is full.
548
+
549
+ Args:
550
+ thread_id: The thread ID to mark as seen.
551
+ """
552
+ # If already present, move to end (most recent)
553
+ if thread_id in self._seen_threads:
554
+ self._seen_threads.move_to_end(thread_id)
555
+ return
556
+
557
+ # Evict oldest entries if at capacity
558
+ while len(self._seen_threads) >= self._max_seen_threads:
559
+ self._seen_threads.popitem(last=False)
560
+
561
+ self._seen_threads[thread_id] = True
562
+
563
+ def clear_seen_threads(self) -> int:
564
+ """Clear the seen threads cache.
565
+
566
+ Returns:
567
+ Number of entries cleared.
568
+ """
569
+ count = len(self._seen_threads)
570
+ self._seen_threads.clear()
571
+ return count
572
+
573
+ def _find_jsonl_path(self, thread_id: str) -> str | None:
574
+ """Find the Codex session JSONL file for a thread.
575
+
576
+ Codex stores sessions at: ~/.codex/sessions/YYYY/MM/DD/rollout-{timestamp}-{thread-id}.jsonl
577
+
578
+ Args:
579
+ thread_id: The Codex thread ID
580
+
581
+ Returns:
582
+ Path to the JSONL file, or None if not found
583
+ """
584
+ if not CODEX_SESSIONS_DIR.exists():
585
+ return None
586
+
587
+ # Search for file ending with thread-id.jsonl
588
+ # Escape special glob characters in thread_id
589
+ safe_thread_id = glob_module.escape(thread_id)
590
+ pattern = str(CODEX_SESSIONS_DIR / "**" / f"*{safe_thread_id}.jsonl")
591
+ matches = glob_module.glob(pattern, recursive=True)
592
+
593
+ if matches:
594
+ # Return the most recent match (in case of duplicates)
595
+ return max(matches, key=os.path.getmtime)
596
+ return None
597
+
598
+ def _get_first_prompt(self, input_messages: list[Any]) -> str | None:
599
+ """Extract the first user prompt from input_messages.
600
+
601
+ Args:
602
+ input_messages: List of user messages from Codex
603
+
604
+ Returns:
605
+ First prompt string, or None
606
+ """
607
+ if input_messages and isinstance(input_messages, list) and len(input_messages) > 0:
608
+ first = input_messages[0]
609
+ if isinstance(first, str):
610
+ return first
611
+ elif isinstance(first, dict):
612
+ return first.get("text") or first.get("content")
613
+ return None
614
+
615
+ def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent | None:
616
+ """Convert Codex notify payload to HookEvent.
617
+
618
+ The native_event structure from /hooks/execute:
619
+ {
620
+ "hook_type": "AgentTurnComplete",
621
+ "input_data": {
622
+ "session_id": "thread-id",
623
+ "event_type": "agent-turn-complete",
624
+ "last_message": "...",
625
+ "input_messages": [...],
626
+ "cwd": "/path/to/project",
627
+ "turn_id": "1"
628
+ },
629
+ "source": "codex"
630
+ }
631
+
632
+ Args:
633
+ native_event: The payload from the HTTP endpoint.
634
+
635
+ Returns:
636
+ HookEvent for processing, or None if unsupported.
637
+ """
638
+ input_data = native_event.get("input_data", {})
639
+ thread_id = input_data.get("session_id", "")
640
+ event_type = input_data.get("event_type", "unknown")
641
+ input_messages = input_data.get("input_messages", [])
642
+ cwd = input_data.get("cwd") or os.getcwd()
643
+
644
+ if not thread_id:
645
+ logger.warning("Codex notify event missing thread_id")
646
+ return None
647
+
648
+ # Find the JSONL transcript file
649
+ jsonl_path = self._find_jsonl_path(thread_id)
650
+
651
+ # Track if this is the first event for this thread (for title synthesis)
652
+ is_first_event = thread_id not in self._seen_threads
653
+ if is_first_event:
654
+ self._mark_thread_seen(thread_id)
655
+
656
+ # Get first prompt for title synthesis (only on first event)
657
+ first_prompt = self._get_first_prompt(input_messages) if is_first_event else None
658
+
659
+ # All Codex notify events are AFTER_AGENT (turn complete)
660
+ # The HookManager will auto-register the session if it doesn't exist
661
+ return HookEvent(
662
+ event_type=HookEventType.AFTER_AGENT,
663
+ session_id=thread_id,
664
+ source=self.source,
665
+ timestamp=datetime.now(UTC),
666
+ machine_id=self._get_machine_id(),
667
+ data={
668
+ "cwd": cwd,
669
+ "event_type": event_type,
670
+ "last_message": input_data.get("last_message", ""),
671
+ "input_messages": input_messages,
672
+ "transcript_path": jsonl_path,
673
+ "is_first_event": is_first_event,
674
+ "prompt": first_prompt, # For title synthesis on first event
675
+ },
676
+ )
677
+
678
+ def translate_from_hook_response(
679
+ self, response: HookResponse, hook_type: str | None = None
680
+ ) -> dict[str, Any]:
681
+ """Convert HookResponse to Codex-expected format.
682
+
683
+ Codex notify doesn't expect a response - it's fire-and-forget.
684
+ This just returns a simple status dict for logging.
685
+
686
+ Args:
687
+ response: The HookResponse from HookManager.
688
+ hook_type: Ignored (notify doesn't need response routing).
689
+
690
+ Returns:
691
+ Simple status dict.
692
+ """
693
+ return {
694
+ "status": "processed",
695
+ "decision": response.decision,
696
+ }
697
+
698
+ def handle_native(
699
+ self, native_event: dict[str, Any], hook_manager: HookManager
700
+ ) -> dict[str, Any]:
701
+ """Process native Codex notify event.
702
+
703
+ Args:
704
+ native_event: The payload from HTTP endpoint.
705
+ hook_manager: HookManager instance for processing.
706
+
707
+ Returns:
708
+ Response dict.
709
+ """
710
+ hook_event = self.translate_to_hook_event(native_event)
711
+ if not hook_event:
712
+ return {"status": "skipped", "message": "Unsupported event"}
713
+
714
+ hook_response = hook_manager.handle(hook_event)
715
+ return self.translate_from_hook_response(hook_response)
716
+
717
+
718
+ __all__ = [
719
+ "_get_machine_id",
720
+ "CodexAdapter",
721
+ "CodexNotifyAdapter",
722
+ ]