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