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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +395 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +385 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +45 -2
- gobby/hooks/hook_manager.py +2 -2
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +2 -0
- gobby/mcp_proxy/registries.py +1 -4
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agents.py +31 -731
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +417 -0
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +52 -18
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/worktrees.py +0 -343
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +1 -1
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/skills/parser.py +30 -2
- gobby/storage/migrations.py +159 -372
- gobby/storage/sessions.py +43 -7
- gobby/storage/skills.py +37 -4
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +269 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +80 -0
- gobby/workflows/safe_evaluator.py +183 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +94 -1
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/RECORD +111 -111
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/mcp_proxy/tools/session_messages.py +0 -1055
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
- {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
|
+
]
|