codex-telegram 0.4.15__tar.gz → 0.4.16__tar.gz
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.
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/PKG-INFO +1 -1
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/app_runtime/bootstrap.py +1 -1
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/bot/callbacks.py +14 -1
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/__init__.py +2 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/approval_flow.py +20 -4
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/client.py +17 -14
- codex-telegram-0.4.16/codex/client_pool.py +224 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/command_router/core.py +2 -2
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/command_router/threads.py +8 -2
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/event_forwarding.py +359 -14
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex_telegram/__init__.py +1 -1
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex_telegram.egg-info/PKG-INFO +1 -1
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex_telegram.egg-info/SOURCES.txt +4 -2
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/main.py +3 -3
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/models/state.py +4 -2
- codex-telegram-0.4.16/tests/test_codex_client_pool.py +111 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_main_approval_flow.py +285 -3
- codex-telegram-0.4.16/tests/test_web_runtime.py +104 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_web_server_local_command.py +59 -1
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/web/routes.py +11 -1
- codex-telegram-0.4.16/web/runtime.py +229 -0
- codex-telegram-0.4.16/web/static/dist/assets/index-C8IolvBK.css +1 -0
- codex-telegram-0.4.16/web/static/dist/assets/index-ERTv2jbE.js +53 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/web/static/dist/index.html +2 -2
- codex-telegram-0.4.15/tests/test_web_runtime.py +0 -38
- codex-telegram-0.4.15/web/runtime.py +0 -124
- codex-telegram-0.4.15/web/static/dist/assets/index-BfBVFmNi.css +0 -1
- codex-telegram-0.4.15/web/static/dist/assets/index-DGTgCn8N.js +0 -53
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/LICENSE +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/MANIFEST.in +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/README.md +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/TELEGRAM.md +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/WEB.md +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/app_runtime/__init__.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/app_runtime/telegram_app.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/app_runtime/web_server.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/bot/__init__.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/bot/features_ui.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/bot/handlers.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/bot/keyboard.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/bot/projects_ui.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/bot/skills_ui.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/bot/thread_ui.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/approval_guardian.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/collaboration_mode.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/command_router/__init__.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/command_router/common.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/command_router/context.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/command_router/contracts.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/command_router/projects.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/command_router/review.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/command_router/system.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/commands.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/events.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex/protocol.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex_telegram/cli.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex_telegram.egg-info/dependency_links.txt +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex_telegram.egg-info/entry_points.txt +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex_telegram.egg-info/requires.txt +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/codex_telegram.egg-info/top_level.txt +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/conf.toml.example +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/models/__init__.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/models/thread.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/models/user.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/pyproject.toml +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/setup.cfg +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_approval_guardian.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_approval_policy.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_bootstrap_update_check.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_callbacks_results.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_cli_entrypoint.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_codex_client.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_command_router.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_guardian_config.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_handlers_results.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_keyboard.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_normalize.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_pypi_check.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_single_instance.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_telegram_bot_config.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/tests/test_thread_history.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/utils/__init__.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/utils/approval_policy.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/utils/config.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/utils/local_command.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/utils/logger.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/utils/normalize.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/utils/pypi_check.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/utils/single_instance.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/utils/web_config.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/web/__init__.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/web/dependencies.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/web/server.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/web/static/dist/icons-tab-add.svg +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/web/static/dist/icons-tab-close.svg +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/web/thread_history.py +0 -0
- {codex-telegram-0.4.15 → codex-telegram-0.4.16}/web/workspace.py +0 -0
|
@@ -84,7 +84,7 @@ async def post_init(
|
|
|
84
84
|
get_config_value("updates.pypi_check_verify_ssl", True),
|
|
85
85
|
default=True,
|
|
86
86
|
)
|
|
87
|
-
if update_check_enabled:
|
|
87
|
+
if update_check_enabled and app is not None:
|
|
88
88
|
logger.info("Checking PyPI for codex-telegram updates...")
|
|
89
89
|
asyncio.create_task(_check_update(verify_ssl=update_check_verify_ssl))
|
|
90
90
|
state.update_notified = True
|
|
@@ -13,6 +13,7 @@ from bot.features_ui import features_keyboard, features_panel_text
|
|
|
13
13
|
from models import state
|
|
14
14
|
from models.user import user_manager
|
|
15
15
|
from codex.commands import CommandResult
|
|
16
|
+
from web.runtime import event_hub
|
|
16
17
|
|
|
17
18
|
logger = logging.getLogger("codex-telegram.bot")
|
|
18
19
|
GUARDIAN_WEB_ONLY_TEXT = "Guardian settings and rules can be edited in Web UI only."
|
|
@@ -352,7 +353,19 @@ async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
|
|
352
353
|
user_id,
|
|
353
354
|
)
|
|
354
355
|
else:
|
|
355
|
-
|
|
356
|
+
pending = await event_hub.list_approvals(user_id)
|
|
357
|
+
thread_id = None
|
|
358
|
+
for item in pending:
|
|
359
|
+
if isinstance(item, dict) and item.get("id") == request_id:
|
|
360
|
+
thread = item.get("thread_id")
|
|
361
|
+
if isinstance(thread, str) and thread:
|
|
362
|
+
thread_id = thread
|
|
363
|
+
break
|
|
364
|
+
accepted = state.codex_client.submit_approval_decision(
|
|
365
|
+
request_id,
|
|
366
|
+
choice,
|
|
367
|
+
thread_id=thread_id,
|
|
368
|
+
)
|
|
356
369
|
if not accepted:
|
|
357
370
|
await edit_with_log(
|
|
358
371
|
query,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
from .client import CodexClient, CodexError
|
|
2
|
+
from .client_pool import CodexClientManager
|
|
2
3
|
from .protocol import Protocol, JSONRPCRequest, JSONRPCResponse, JSONRPCNotification
|
|
3
4
|
from .events import EventHandler, create_event_handler
|
|
4
5
|
from .commands import CommandRouter
|
|
5
6
|
|
|
6
7
|
__all__ = [
|
|
7
8
|
"CodexClient",
|
|
9
|
+
"CodexClientManager",
|
|
8
10
|
"CodexError",
|
|
9
11
|
"Protocol",
|
|
10
12
|
"JSONRPCRequest",
|
|
@@ -143,7 +143,11 @@ def build_approval_request_handler(
|
|
|
143
143
|
),
|
|
144
144
|
)
|
|
145
145
|
else:
|
|
146
|
-
accepted = state.codex_client.submit_approval_decision(
|
|
146
|
+
accepted = state.codex_client.submit_approval_decision(
|
|
147
|
+
req_id,
|
|
148
|
+
policy_match.action,
|
|
149
|
+
thread_id if isinstance(thread_id, str) else None,
|
|
150
|
+
)
|
|
147
151
|
if accepted:
|
|
148
152
|
if user_id > 0 and app is not None:
|
|
149
153
|
await app.bot.send_message(
|
|
@@ -206,7 +210,11 @@ def build_approval_request_handler(
|
|
|
206
210
|
guardian_message(guardian_decision),
|
|
207
211
|
)
|
|
208
212
|
if guardian_decision.choice in {"approve", "session"}:
|
|
209
|
-
accepted = state.codex_client.submit_approval_decision(
|
|
213
|
+
accepted = state.codex_client.submit_approval_decision(
|
|
214
|
+
req_id,
|
|
215
|
+
guardian_decision.choice,
|
|
216
|
+
thread_id if isinstance(thread_id, str) else None,
|
|
217
|
+
)
|
|
210
218
|
if accepted:
|
|
211
219
|
if user_id > 0 and app is not None:
|
|
212
220
|
await app.bot.send_message(chat_id=user_id, text=guardian_message(guardian_decision))
|
|
@@ -244,7 +252,11 @@ def build_approval_request_handler(
|
|
|
244
252
|
f"Reason: {guardian_error or 'unknown'}",
|
|
245
253
|
)
|
|
246
254
|
if config.failure_policy in {"approve", "session", "deny"}:
|
|
247
|
-
accepted = state.codex_client.submit_approval_decision(
|
|
255
|
+
accepted = state.codex_client.submit_approval_decision(
|
|
256
|
+
req_id,
|
|
257
|
+
config.failure_policy,
|
|
258
|
+
thread_id if isinstance(thread_id, str) else None,
|
|
259
|
+
)
|
|
248
260
|
if accepted:
|
|
249
261
|
if user_id > 0 and app is not None:
|
|
250
262
|
await app.bot.send_message(
|
|
@@ -300,7 +312,11 @@ def build_approval_request_handler(
|
|
|
300
312
|
previous_id = previous.get("id")
|
|
301
313
|
if not isinstance(previous_id, int) or previous_id == req_id:
|
|
302
314
|
continue
|
|
303
|
-
closed = state.codex_client.submit_approval_decision(
|
|
315
|
+
closed = state.codex_client.submit_approval_decision(
|
|
316
|
+
previous_id,
|
|
317
|
+
"deny",
|
|
318
|
+
previous.get("thread_id") if isinstance(previous.get("thread_id"), str) else None,
|
|
319
|
+
)
|
|
304
320
|
logger.info(
|
|
305
321
|
"Superseding approval request user_id=%s previous_request_id=%s new_request_id=%s closed=%s",
|
|
306
322
|
user_id,
|
|
@@ -23,6 +23,7 @@ class CodexClient:
|
|
|
23
23
|
self._proc: asyncio.subprocess.Process | None = None
|
|
24
24
|
self._reader_task: asyncio.Task | None = None
|
|
25
25
|
self._stderr_task: asyncio.Task | None = None
|
|
26
|
+
self._call_lock = asyncio.Lock()
|
|
26
27
|
self._pending: dict[int, asyncio.Future[JSONRPCResponse]] = {}
|
|
27
28
|
self._pending_approvals: dict[int, _ApprovalWaiter] = {}
|
|
28
29
|
self._event_handlers: dict[str, list[Callable]] = {}
|
|
@@ -95,19 +96,21 @@ class CodexClient:
|
|
|
95
96
|
return result
|
|
96
97
|
|
|
97
98
|
async def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
99
|
+
async with self._call_lock:
|
|
100
|
+
request = self.protocol.create_request(method, {} if params is None else params)
|
|
101
|
+
req_id = request.id
|
|
102
|
+
if req_id is None:
|
|
103
|
+
raise ValueError("Request ID is None")
|
|
104
|
+
loop = asyncio.get_running_loop()
|
|
105
|
+
future: asyncio.Future[JSONRPCResponse] = loop.create_future()
|
|
106
|
+
self._pending[req_id] = future
|
|
107
|
+
|
|
108
|
+
self._write(request)
|
|
109
|
+
|
|
110
|
+
response = await future
|
|
111
|
+
if response.error:
|
|
112
|
+
raise CodexError(response.error["code"], response.error["message"])
|
|
113
|
+
return response.result
|
|
111
114
|
|
|
112
115
|
def on(self, method: str, handler: Callable):
|
|
113
116
|
if method not in self._event_handlers:
|
|
@@ -120,7 +123,7 @@ class CodexClient:
|
|
|
120
123
|
def on_approval_request(self, handler: Callable):
|
|
121
124
|
self._approval_handlers.append(handler)
|
|
122
125
|
|
|
123
|
-
def submit_approval_decision(self, request_id: int, decision: str) -> bool:
|
|
126
|
+
def submit_approval_decision(self, request_id: int, decision: str, thread_id: str | None = None) -> bool:
|
|
124
127
|
waiter = self._pending_approvals.get(request_id)
|
|
125
128
|
if waiter is None or waiter.future.done():
|
|
126
129
|
return False
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from inspect import isawaitable
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from .client import CodexClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("codex-telegram.codex")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class _ManagedClient:
|
|
16
|
+
client: CodexClient
|
|
17
|
+
thread_ids: set[str] = field(default_factory=set)
|
|
18
|
+
kind: str = "thread"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CodexClientManager:
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self._client_info: dict[str, Any] | None = None
|
|
24
|
+
self._control: _ManagedClient | None = None
|
|
25
|
+
self._thread_clients: dict[str, _ManagedClient] = {}
|
|
26
|
+
self._approval_request_clients: dict[int, _ManagedClient] = {}
|
|
27
|
+
self._any_event_handlers: list[Callable] = []
|
|
28
|
+
self._event_handlers: dict[str, list[Callable]] = {}
|
|
29
|
+
self._approval_handlers: list[Callable] = []
|
|
30
|
+
self._lock = threading.RLock()
|
|
31
|
+
|
|
32
|
+
async def start(self):
|
|
33
|
+
if self._control is not None:
|
|
34
|
+
return
|
|
35
|
+
self._control = await self._create_started_client(kind="control")
|
|
36
|
+
logger.info("Codex control client started")
|
|
37
|
+
|
|
38
|
+
async def initialize(self, client_info: dict[str, Any]):
|
|
39
|
+
self._client_info = dict(client_info)
|
|
40
|
+
if self._control is None:
|
|
41
|
+
await self.start()
|
|
42
|
+
if self._control is None:
|
|
43
|
+
raise RuntimeError("Codex control client failed to start")
|
|
44
|
+
await self._control.client.initialize(client_info)
|
|
45
|
+
logger.info("Initialized Codex control client")
|
|
46
|
+
|
|
47
|
+
async def stop(self):
|
|
48
|
+
with self._lock:
|
|
49
|
+
thread_clients = list(self._thread_clients.items())
|
|
50
|
+
control = self._control
|
|
51
|
+
self._thread_clients.clear()
|
|
52
|
+
self._approval_request_clients.clear()
|
|
53
|
+
self._control = None
|
|
54
|
+
|
|
55
|
+
for thread_id, handle in thread_clients:
|
|
56
|
+
try:
|
|
57
|
+
await handle.client.stop()
|
|
58
|
+
except Exception:
|
|
59
|
+
logger.exception("Failed to stop Codex thread client thread_id=%s", thread_id)
|
|
60
|
+
|
|
61
|
+
if control is not None:
|
|
62
|
+
try:
|
|
63
|
+
await control.client.stop()
|
|
64
|
+
except Exception:
|
|
65
|
+
logger.exception("Failed to stop Codex control client")
|
|
66
|
+
|
|
67
|
+
def on(self, method: str, handler: Callable):
|
|
68
|
+
with self._lock:
|
|
69
|
+
self._event_handlers.setdefault(method, []).append(handler)
|
|
70
|
+
for handle in self._thread_clients.values():
|
|
71
|
+
handle.client.on(method, handler)
|
|
72
|
+
if self._control is not None:
|
|
73
|
+
self._control.client.on(method, handler)
|
|
74
|
+
|
|
75
|
+
def on_any(self, handler: Callable):
|
|
76
|
+
with self._lock:
|
|
77
|
+
self._any_event_handlers.append(handler)
|
|
78
|
+
|
|
79
|
+
def on_approval_request(self, handler: Callable):
|
|
80
|
+
with self._lock:
|
|
81
|
+
self._approval_handlers.append(handler)
|
|
82
|
+
|
|
83
|
+
def submit_approval_decision(self, request_id: int, decision: str, thread_id: str | None = None) -> bool:
|
|
84
|
+
handle = self._resolve_approval_handle(request_id, thread_id)
|
|
85
|
+
if handle is None:
|
|
86
|
+
return False
|
|
87
|
+
return handle.client.submit_approval_decision(request_id, decision, thread_id=thread_id)
|
|
88
|
+
|
|
89
|
+
async def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
90
|
+
normalized_params = {} if params is None else params
|
|
91
|
+
thread_id = self._extract_thread_id(normalized_params)
|
|
92
|
+
if method == "thread/start":
|
|
93
|
+
handle = await self._create_managed_client(kind="thread")
|
|
94
|
+
try:
|
|
95
|
+
result = await handle.client.call(method, normalized_params)
|
|
96
|
+
started_thread_id = self._extract_thread_id_from_result(result)
|
|
97
|
+
if not started_thread_id:
|
|
98
|
+
raise RuntimeError("Codex thread/start did not return a thread id")
|
|
99
|
+
self._bind_thread_handle(started_thread_id, handle)
|
|
100
|
+
return result
|
|
101
|
+
except Exception:
|
|
102
|
+
await handle.client.stop()
|
|
103
|
+
raise
|
|
104
|
+
if thread_id is not None:
|
|
105
|
+
handle, is_new = await self._resolve_thread_handle(thread_id)
|
|
106
|
+
try:
|
|
107
|
+
result = await handle.client.call(method, normalized_params)
|
|
108
|
+
self._bind_thread_handle(thread_id, handle)
|
|
109
|
+
return result
|
|
110
|
+
except Exception:
|
|
111
|
+
if is_new:
|
|
112
|
+
self._unbind_thread_handle(thread_id, handle)
|
|
113
|
+
await handle.client.stop()
|
|
114
|
+
raise
|
|
115
|
+
control = await self._ensure_control_client()
|
|
116
|
+
return await control.client.call(method, normalized_params)
|
|
117
|
+
|
|
118
|
+
async def _ensure_control_client(self) -> _ManagedClient:
|
|
119
|
+
if self._control is not None:
|
|
120
|
+
return self._control
|
|
121
|
+
await self.start()
|
|
122
|
+
if self._control is None:
|
|
123
|
+
raise RuntimeError("Codex control client is unavailable")
|
|
124
|
+
return self._control
|
|
125
|
+
|
|
126
|
+
async def _resolve_thread_handle(self, thread_id: str) -> tuple[_ManagedClient, bool]:
|
|
127
|
+
with self._lock:
|
|
128
|
+
handle = self._thread_clients.get(thread_id)
|
|
129
|
+
if handle is not None:
|
|
130
|
+
return handle, False
|
|
131
|
+
handle = await self._create_managed_client(kind="thread")
|
|
132
|
+
self._bind_thread_handle(thread_id, handle)
|
|
133
|
+
return handle, True
|
|
134
|
+
|
|
135
|
+
def _bind_thread_handle(self, thread_id: str, handle: _ManagedClient) -> None:
|
|
136
|
+
with self._lock:
|
|
137
|
+
self._thread_clients[thread_id] = handle
|
|
138
|
+
handle.thread_ids.add(thread_id)
|
|
139
|
+
|
|
140
|
+
def _unbind_thread_handle(self, thread_id: str, handle: _ManagedClient) -> None:
|
|
141
|
+
with self._lock:
|
|
142
|
+
current = self._thread_clients.get(thread_id)
|
|
143
|
+
if current is handle:
|
|
144
|
+
self._thread_clients.pop(thread_id, None)
|
|
145
|
+
handle.thread_ids.discard(thread_id)
|
|
146
|
+
|
|
147
|
+
def _resolve_approval_handle(self, request_id: int, thread_id: str | None = None) -> _ManagedClient | None:
|
|
148
|
+
with self._lock:
|
|
149
|
+
if isinstance(thread_id, str) and thread_id:
|
|
150
|
+
handle = self._thread_clients.get(thread_id)
|
|
151
|
+
if handle is not None:
|
|
152
|
+
return handle
|
|
153
|
+
return self._approval_request_clients.get(request_id)
|
|
154
|
+
|
|
155
|
+
def _extract_thread_id(self, params: dict[str, Any]) -> str | None:
|
|
156
|
+
for key in ("threadId", "conversationId"):
|
|
157
|
+
value = params.get(key)
|
|
158
|
+
if isinstance(value, str) and value:
|
|
159
|
+
return value
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def _extract_thread_id_from_result(self, result: Any) -> str | None:
|
|
163
|
+
if not isinstance(result, dict):
|
|
164
|
+
return None
|
|
165
|
+
thread = result.get("thread")
|
|
166
|
+
if isinstance(thread, dict):
|
|
167
|
+
thread_id = thread.get("id")
|
|
168
|
+
if isinstance(thread_id, str) and thread_id:
|
|
169
|
+
return thread_id
|
|
170
|
+
thread_id = result.get("threadId")
|
|
171
|
+
if isinstance(thread_id, str) and thread_id:
|
|
172
|
+
return thread_id
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
async def _create_started_client(self, *, kind: str) -> _ManagedClient:
|
|
176
|
+
client = CodexClient()
|
|
177
|
+
handle = _ManagedClient(client=client, kind=kind)
|
|
178
|
+
self._attach_handlers(handle)
|
|
179
|
+
await client.start()
|
|
180
|
+
return handle
|
|
181
|
+
|
|
182
|
+
async def _create_managed_client(self, *, kind: str) -> _ManagedClient:
|
|
183
|
+
handle = await self._create_started_client(kind=kind)
|
|
184
|
+
if self._client_info is None:
|
|
185
|
+
raise RuntimeError("Codex client manager has not been initialized")
|
|
186
|
+
await handle.client.initialize(self._client_info)
|
|
187
|
+
return handle
|
|
188
|
+
|
|
189
|
+
def _attach_handlers(self, handle: _ManagedClient) -> None:
|
|
190
|
+
handle.client.on_any(lambda method, params, _handle=handle: self._dispatch_any(_handle, method, params))
|
|
191
|
+
handle.client.on_approval_request(
|
|
192
|
+
lambda payload, _handle=handle: self._dispatch_approval(_handle, payload)
|
|
193
|
+
)
|
|
194
|
+
for method, handlers in self._event_handlers.items():
|
|
195
|
+
for handler in handlers:
|
|
196
|
+
handle.client.on(method, handler)
|
|
197
|
+
|
|
198
|
+
async def _dispatch_any(self, handle: _ManagedClient, method: str, params: dict[str, Any] | None) -> None:
|
|
199
|
+
for handler in list(self._any_event_handlers):
|
|
200
|
+
await self._run_handler(handler, method, params, wildcard=True)
|
|
201
|
+
for handler in list(self._event_handlers.get(method, [])):
|
|
202
|
+
await self._run_handler(handler, method, params, wildcard=False)
|
|
203
|
+
|
|
204
|
+
async def _dispatch_approval(self, handle: _ManagedClient, payload: dict[str, Any]) -> None:
|
|
205
|
+
request_id = payload.get("id")
|
|
206
|
+
if isinstance(request_id, int):
|
|
207
|
+
with self._lock:
|
|
208
|
+
self._approval_request_clients[request_id] = handle
|
|
209
|
+
for handler in list(self._approval_handlers):
|
|
210
|
+
try:
|
|
211
|
+
result = handler(payload)
|
|
212
|
+
if isawaitable(result):
|
|
213
|
+
await result
|
|
214
|
+
except Exception:
|
|
215
|
+
logger.exception("Error in approval request handler request_id=%s", request_id)
|
|
216
|
+
|
|
217
|
+
async def _run_handler(self, handler: Callable, method: str, params: dict[str, Any] | None, *, wildcard: bool):
|
|
218
|
+
try:
|
|
219
|
+
result = handler(method, params) if wildcard else handler(params)
|
|
220
|
+
if isawaitable(result):
|
|
221
|
+
await result
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
label = "wildcard event handler" if wildcard else "event handler"
|
|
224
|
+
logger.error("Error in %s for %s: %s", label, method, exc)
|
|
@@ -94,8 +94,8 @@ class CommandRouter:
|
|
|
94
94
|
async def _dispatch_compact(self, args: list[str], _user_id: int) -> CommandResult:
|
|
95
95
|
return await self.threads.compact(args)
|
|
96
96
|
|
|
97
|
-
async def _dispatch_rollback(self, args: list[str],
|
|
98
|
-
return await self.threads.rollback(args)
|
|
97
|
+
async def _dispatch_rollback(self, args: list[str], user_id: int) -> CommandResult:
|
|
98
|
+
return await self.threads.rollback(args, user_id)
|
|
99
99
|
|
|
100
100
|
async def _dispatch_interrupt(self, args: list[str], user_id: int) -> CommandResult:
|
|
101
101
|
return await self.threads.interrupt(user_id, args)
|
|
@@ -480,12 +480,18 @@ class ThreadCommands:
|
|
|
480
480
|
await self.ctx.codex.call("thread/compact/start", {"threadId": thread_id})
|
|
481
481
|
return text_result(f"Compaction started: {thread_id}", thread_id=thread_id)
|
|
482
482
|
|
|
483
|
-
async def rollback(self, args: list[str]) -> CommandResult:
|
|
483
|
+
async def rollback(self, args: list[str], user_id: int) -> CommandResult:
|
|
484
|
+
from models.user import user_manager
|
|
485
|
+
|
|
484
486
|
if not args:
|
|
485
487
|
return usage_result("Usage: /rollback <n_turns>")
|
|
486
488
|
|
|
487
489
|
n = int(args[0])
|
|
488
|
-
|
|
490
|
+
active_thread_id = user_manager.get(user_id).active_thread_id
|
|
491
|
+
if not active_thread_id:
|
|
492
|
+
return text_result("No active thread.")
|
|
493
|
+
|
|
494
|
+
result = await self.ctx.codex.call("thread/rollback", {"threadId": active_thread_id, "n": n})
|
|
489
495
|
thread = result.get("thread", {})
|
|
490
496
|
return text_result(f"Rolled back. Thread: {thread.get('id', 'unknown')}")
|
|
491
497
|
|