codex-telegram 0.4.14__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.
Files changed (97) hide show
  1. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/PKG-INFO +1 -1
  2. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/app_runtime/bootstrap.py +1 -1
  3. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/bot/callbacks.py +14 -1
  4. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/__init__.py +2 -0
  5. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/approval_flow.py +20 -4
  6. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/client.py +17 -14
  7. codex-telegram-0.4.16/codex/client_pool.py +224 -0
  8. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/command_router/core.py +2 -2
  9. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/command_router/threads.py +8 -2
  10. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/event_forwarding.py +359 -14
  11. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex_telegram/__init__.py +1 -1
  12. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex_telegram.egg-info/PKG-INFO +1 -1
  13. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex_telegram.egg-info/SOURCES.txt +4 -2
  14. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/main.py +3 -3
  15. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/models/state.py +4 -2
  16. codex-telegram-0.4.16/tests/test_codex_client_pool.py +111 -0
  17. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_main_approval_flow.py +285 -3
  18. codex-telegram-0.4.16/tests/test_web_runtime.py +104 -0
  19. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_web_server_local_command.py +59 -1
  20. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/web/routes.py +11 -1
  21. codex-telegram-0.4.16/web/runtime.py +229 -0
  22. codex-telegram-0.4.16/web/static/dist/assets/index-C8IolvBK.css +1 -0
  23. codex-telegram-0.4.16/web/static/dist/assets/index-ERTv2jbE.js +53 -0
  24. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/web/static/dist/index.html +2 -2
  25. codex-telegram-0.4.14/tests/test_web_runtime.py +0 -38
  26. codex-telegram-0.4.14/web/runtime.py +0 -124
  27. codex-telegram-0.4.14/web/static/dist/assets/index-BfBVFmNi.css +0 -1
  28. codex-telegram-0.4.14/web/static/dist/assets/index-ZvcGuXeL.js +0 -53
  29. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/LICENSE +0 -0
  30. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/MANIFEST.in +0 -0
  31. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/README.md +0 -0
  32. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/TELEGRAM.md +0 -0
  33. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/WEB.md +0 -0
  34. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/app_runtime/__init__.py +0 -0
  35. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/app_runtime/telegram_app.py +0 -0
  36. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/app_runtime/web_server.py +0 -0
  37. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/bot/__init__.py +0 -0
  38. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/bot/features_ui.py +0 -0
  39. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/bot/handlers.py +0 -0
  40. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/bot/keyboard.py +0 -0
  41. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/bot/projects_ui.py +0 -0
  42. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/bot/skills_ui.py +0 -0
  43. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/bot/thread_ui.py +0 -0
  44. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/approval_guardian.py +0 -0
  45. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/collaboration_mode.py +0 -0
  46. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/command_router/__init__.py +0 -0
  47. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/command_router/common.py +0 -0
  48. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/command_router/context.py +0 -0
  49. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/command_router/contracts.py +0 -0
  50. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/command_router/projects.py +0 -0
  51. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/command_router/review.py +0 -0
  52. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/command_router/system.py +0 -0
  53. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/commands.py +0 -0
  54. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/events.py +0 -0
  55. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex/protocol.py +0 -0
  56. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex_telegram/cli.py +0 -0
  57. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex_telegram.egg-info/dependency_links.txt +0 -0
  58. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex_telegram.egg-info/entry_points.txt +0 -0
  59. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex_telegram.egg-info/requires.txt +0 -0
  60. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/codex_telegram.egg-info/top_level.txt +0 -0
  61. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/conf.toml.example +0 -0
  62. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/models/__init__.py +0 -0
  63. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/models/thread.py +0 -0
  64. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/models/user.py +0 -0
  65. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/pyproject.toml +0 -0
  66. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/setup.cfg +0 -0
  67. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_approval_guardian.py +0 -0
  68. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_approval_policy.py +0 -0
  69. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_bootstrap_update_check.py +0 -0
  70. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_callbacks_results.py +0 -0
  71. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_cli_entrypoint.py +0 -0
  72. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_codex_client.py +0 -0
  73. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_command_router.py +0 -0
  74. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_guardian_config.py +0 -0
  75. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_handlers_results.py +0 -0
  76. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_keyboard.py +0 -0
  77. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_normalize.py +0 -0
  78. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_pypi_check.py +0 -0
  79. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_single_instance.py +0 -0
  80. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_telegram_bot_config.py +0 -0
  81. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/tests/test_thread_history.py +0 -0
  82. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/utils/__init__.py +0 -0
  83. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/utils/approval_policy.py +0 -0
  84. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/utils/config.py +0 -0
  85. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/utils/local_command.py +0 -0
  86. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/utils/logger.py +0 -0
  87. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/utils/normalize.py +0 -0
  88. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/utils/pypi_check.py +0 -0
  89. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/utils/single_instance.py +0 -0
  90. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/utils/web_config.py +0 -0
  91. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/web/__init__.py +0 -0
  92. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/web/dependencies.py +0 -0
  93. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/web/server.py +0 -0
  94. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/web/static/dist/icons-tab-add.svg +0 -0
  95. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/web/static/dist/icons-tab-close.svg +0 -0
  96. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/web/thread_history.py +0 -0
  97. {codex-telegram-0.4.14 → codex-telegram-0.4.16}/web/workspace.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: codex-telegram
3
- Version: 0.4.14
3
+ Version: 0.4.16
4
4
  Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
5
5
  Author: heungtae
6
6
  License: Apache License
@@ -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
- accepted = state.codex_client.submit_approval_decision(request_id, choice)
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(req_id, policy_match.action)
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(req_id, guardian_decision.choice)
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(req_id, config.failure_policy)
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(previous_id, "deny")
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
- request = self.protocol.create_request(method, {} if params is None else params)
99
- req_id = request.id
100
- if req_id is None:
101
- raise ValueError("Request ID is None")
102
- future: asyncio.Future[JSONRPCResponse] = asyncio.Future()
103
- self._pending[req_id] = future
104
-
105
- self._write(request)
106
-
107
- response = await future
108
- if response.error:
109
- raise CodexError(response.error["code"], response.error["message"])
110
- return response.result
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], _user_id: int) -> CommandResult:
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
- result = await self.ctx.codex.call("thread/rollback", {"n": n})
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