codex-autorunner 0.1.1__py3-none-any.whl → 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,2043 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any, Optional, Sequence
7
+
8
+ from .....agents.opencode.runtime import extract_session_id
9
+ from .....core.logging_utils import log_event
10
+ from .....core.state import now_iso
11
+ from .....core.utils import canonicalize_path, resolve_opencode_binary
12
+ from .....manifest import load_manifest
13
+ from ....app_server.client import (
14
+ CodexAppServerClient,
15
+ )
16
+ from ...adapter import (
17
+ TelegramCallbackQuery,
18
+ TelegramMessage,
19
+ )
20
+ from ...config import AppServerUnavailableError
21
+ from ...constants import (
22
+ AGENT_PICKER_PROMPT,
23
+ BIND_PICKER_PROMPT,
24
+ DEFAULT_AGENT,
25
+ DEFAULT_AGENT_MODELS,
26
+ DEFAULT_PAGE_SIZE,
27
+ MAX_TOPIC_THREAD_HISTORY,
28
+ RESUME_MISSING_IDS_LOG_LIMIT,
29
+ RESUME_PICKER_PROMPT,
30
+ RESUME_REFRESH_LIMIT,
31
+ THREAD_LIST_MAX_PAGES,
32
+ VALID_AGENT_VALUES,
33
+ )
34
+ from ...helpers import (
35
+ _approval_age_seconds,
36
+ _coerce_thread_list,
37
+ _extract_first_user_preview,
38
+ _extract_thread_id,
39
+ _extract_thread_info,
40
+ _extract_thread_list_cursor,
41
+ _extract_thread_preview_parts,
42
+ _format_missing_thread_label,
43
+ _format_rate_limits,
44
+ _format_resume_summary,
45
+ _format_sandbox_policy,
46
+ _format_thread_preview,
47
+ _format_token_usage,
48
+ _local_workspace_threads,
49
+ _page_slice,
50
+ _partition_threads,
51
+ _paths_compatible,
52
+ _resume_thread_list_limit,
53
+ _set_thread_summary,
54
+ _split_topic_key,
55
+ _thread_summary_preview,
56
+ _with_conversation_id,
57
+ )
58
+ from ...state import APPROVAL_MODE_YOLO, normalize_agent
59
+ from ...types import SelectionState
60
+ from .shared import SharedHelpers
61
+
62
+ if TYPE_CHECKING:
63
+ from ...state import TelegramTopicRecord
64
+
65
+
66
+ def _extract_opencode_session_path(payload: Any) -> Optional[str]:
67
+ if not isinstance(payload, dict):
68
+ return None
69
+ for key in ("directory", "path", "workspace_path", "workspacePath"):
70
+ value = payload.get(key)
71
+ if isinstance(value, str) and value:
72
+ return value
73
+ properties = payload.get("properties")
74
+ if isinstance(properties, dict):
75
+ for key in ("directory", "path", "workspace_path", "workspacePath"):
76
+ value = properties.get(key)
77
+ if isinstance(value, str) and value:
78
+ return value
79
+ session = payload.get("session")
80
+ if isinstance(session, dict):
81
+ return _extract_opencode_session_path(session)
82
+ return None
83
+
84
+
85
+ @dataclass
86
+ class ResumeCommandArgs:
87
+ """Parsed /resume command options."""
88
+
89
+ trimmed: str
90
+ remaining: list[str]
91
+ show_unscoped: bool
92
+ refresh: bool
93
+
94
+
95
+ @dataclass
96
+ class ResumeThreadData:
97
+ """Thread listing details used to render the resume picker."""
98
+
99
+ candidates: list[dict[str, Any]]
100
+ entries_by_id: dict[str, dict[str, Any]]
101
+ local_thread_ids: list[str]
102
+ local_previews: dict[str, str]
103
+ local_thread_topics: dict[str, set[str]]
104
+ list_failed: bool
105
+ threads: list[dict[str, Any]]
106
+ unscoped_entries: list[dict[str, Any]]
107
+ saw_path: bool
108
+
109
+
110
+ class WorkspaceCommands(SharedHelpers):
111
+ async def _apply_agent_change(
112
+ self,
113
+ chat_id: int,
114
+ thread_id: Optional[int],
115
+ desired: str,
116
+ ) -> str:
117
+ def apply(record: "TelegramTopicRecord") -> None:
118
+ record.agent = desired
119
+ record.active_thread_id = None
120
+ record.thread_ids.clear()
121
+ record.thread_summaries.clear()
122
+ record.pending_compact_seed = None
123
+ record.pending_compact_seed_thread_id = None
124
+ if not self._agent_supports_effort(desired):
125
+ record.effort = None
126
+ record.model = DEFAULT_AGENT_MODELS.get(desired)
127
+
128
+ await self._router.update_topic(chat_id, thread_id, apply)
129
+ if not self._agent_supports_resume(desired):
130
+ return " (resume not supported)"
131
+ return ""
132
+
133
+ async def _handle_agent(
134
+ self, message: TelegramMessage, args: str, _runtime: Any
135
+ ) -> None:
136
+ record = await self._router.ensure_topic(message.chat_id, message.thread_id)
137
+ current = self._effective_agent(record)
138
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
139
+ self._agent_options.pop(key, None)
140
+ argv = self._parse_command_args(args)
141
+ if not argv:
142
+ availability = "available"
143
+ if not self._opencode_available():
144
+ availability = "missing binary"
145
+ items = []
146
+ for agent in ("codex", "opencode"):
147
+ if agent not in VALID_AGENT_VALUES:
148
+ continue
149
+ label = agent
150
+ if agent == current:
151
+ label = f"{label} (current)"
152
+ if agent == "opencode" and availability != "available":
153
+ label = f"{label} ({availability})"
154
+ items.append((agent, label))
155
+ state = SelectionState(items=items)
156
+ keyboard = self._build_agent_keyboard(state)
157
+ self._agent_options[key] = state
158
+ self._touch_cache_timestamp("agent_options", key)
159
+ await self._send_message(
160
+ message.chat_id,
161
+ self._selection_prompt(AGENT_PICKER_PROMPT, state),
162
+ thread_id=message.thread_id,
163
+ reply_to=message.message_id,
164
+ reply_markup=keyboard,
165
+ )
166
+ return
167
+ desired = normalize_agent(argv[0])
168
+ try:
169
+ client = await self._client_for_workspace(record.workspace_path)
170
+ except AppServerUnavailableError as exc:
171
+ log_event(
172
+ self._logger,
173
+ logging.WARNING,
174
+ "telegram.app_server.unavailable",
175
+ chat_id=message.chat_id,
176
+ thread_id=message.thread_id,
177
+ exc=exc,
178
+ )
179
+ await self._send_message(
180
+ message.chat_id,
181
+ "App server unavailable; try again or check logs.",
182
+ thread_id=message.thread_id,
183
+ reply_to=message.message_id,
184
+ )
185
+ return
186
+ if client is None:
187
+ await self._send_message(
188
+ message.chat_id,
189
+ "Topic not bound. Use /bind <repo_id> or /bind <path>.",
190
+ thread_id=message.thread_id,
191
+ reply_to=message.message_id,
192
+ )
193
+ return
194
+ if desired == "opencode" and not self._opencode_available():
195
+ await self._send_message(
196
+ message.chat_id,
197
+ "OpenCode binary not found. Install opencode or switch to /agent codex.",
198
+ thread_id=message.thread_id,
199
+ reply_to=message.message_id,
200
+ )
201
+ return
202
+ if desired == current:
203
+ await self._send_message(
204
+ message.chat_id,
205
+ f"Agent already set to {current}.",
206
+ thread_id=message.thread_id,
207
+ reply_to=message.message_id,
208
+ )
209
+ return
210
+ note = await self._apply_agent_change(
211
+ message.chat_id, message.thread_id, desired
212
+ )
213
+ await self._send_message(
214
+ message.chat_id,
215
+ f"Agent set to {desired}{note}.",
216
+ thread_id=message.thread_id,
217
+ reply_to=message.message_id,
218
+ )
219
+
220
+ def _effective_policies(
221
+ self, record: "TelegramTopicRecord"
222
+ ) -> tuple[Optional[str], Optional[Any]]:
223
+ approval_policy, sandbox_policy = self._config.defaults.policies_for_mode(
224
+ record.approval_mode
225
+ )
226
+ if record.approval_policy is not None:
227
+ approval_policy = record.approval_policy
228
+ if record.sandbox_policy is not None:
229
+ sandbox_policy = record.sandbox_policy
230
+ return approval_policy, sandbox_policy
231
+
232
+ def _effective_agent(self, record: Optional["TelegramTopicRecord"]) -> str:
233
+ if record and record.agent in VALID_AGENT_VALUES:
234
+ return record.agent
235
+ return DEFAULT_AGENT
236
+
237
+ def _agent_supports_effort(self, agent: str) -> bool:
238
+ return agent == "codex"
239
+
240
+ def _agent_supports_resume(self, agent: str) -> bool:
241
+ return agent in ("codex", "opencode")
242
+
243
+ def _agent_rate_limit_source(self, agent: str) -> Optional[str]:
244
+ if agent == "codex":
245
+ return "app_server"
246
+ return None
247
+
248
+ def _opencode_available(self) -> bool:
249
+ opencode_command = self._config.opencode_command
250
+ if opencode_command and resolve_opencode_binary(opencode_command[0]):
251
+ return True
252
+ binary = self._config.agent_binaries.get("opencode")
253
+ if not binary:
254
+ return False
255
+ return resolve_opencode_binary(binary) is not None
256
+
257
+ async def _fetch_model_list(
258
+ self,
259
+ record: Optional["TelegramTopicRecord"],
260
+ *,
261
+ agent: str,
262
+ client: CodexAppServerClient,
263
+ list_params: dict[str, Any],
264
+ ) -> Any:
265
+ if agent == "opencode":
266
+ supervisor = getattr(self, "_opencode_supervisor", None)
267
+ if supervisor is None:
268
+ from .....agents.opencode.supervisor import OpenCodeSupervisorError
269
+
270
+ raise OpenCodeSupervisorError("OpenCode backend is not configured")
271
+ workspace_root = self._canonical_workspace_root(
272
+ record.workspace_path if record else None
273
+ )
274
+ if workspace_root is None:
275
+ from .....agents.opencode.supervisor import OpenCodeSupervisorError
276
+
277
+ raise OpenCodeSupervisorError("OpenCode workspace is unavailable")
278
+ from .....agents.opencode.harness import OpenCodeHarness
279
+
280
+ harness = OpenCodeHarness(supervisor)
281
+ catalog = await harness.model_catalog(workspace_root)
282
+ return [
283
+ {
284
+ "id": model.id,
285
+ "displayName": model.display_name,
286
+ }
287
+ for model in catalog.models
288
+ ]
289
+ return await client.request("model/list", list_params)
290
+
291
+ async def _verify_active_thread(
292
+ self, message: TelegramMessage, record: "TelegramTopicRecord"
293
+ ) -> Optional["TelegramTopicRecord"]:
294
+ agent = self._effective_agent(record)
295
+ if agent == "opencode":
296
+ if not record.active_thread_id:
297
+ return record
298
+ supervisor = getattr(self, "_opencode_supervisor", None)
299
+ if supervisor is None:
300
+ await self._send_message(
301
+ message.chat_id,
302
+ "OpenCode backend unavailable; install opencode or switch to /agent codex.",
303
+ thread_id=message.thread_id,
304
+ reply_to=message.message_id,
305
+ )
306
+ return await self._router.set_active_thread(
307
+ message.chat_id, message.thread_id, None
308
+ )
309
+ workspace_root = self._canonical_workspace_root(record.workspace_path)
310
+ if workspace_root is None:
311
+ return record
312
+ try:
313
+ client = await supervisor.get_client(workspace_root)
314
+ await client.get_session(record.active_thread_id)
315
+ return record
316
+ except Exception:
317
+ return await self._router.set_active_thread(
318
+ message.chat_id, message.thread_id, None
319
+ )
320
+ if not self._agent_supports_resume(agent):
321
+ return record
322
+ thread_id = record.active_thread_id
323
+ if not thread_id:
324
+ return record
325
+ try:
326
+ client = await self._client_for_workspace(record.workspace_path)
327
+ except AppServerUnavailableError as exc:
328
+ log_event(
329
+ self._logger,
330
+ logging.WARNING,
331
+ "telegram.app_server.unavailable",
332
+ chat_id=message.chat_id,
333
+ thread_id=message.thread_id,
334
+ exc=exc,
335
+ )
336
+ await self._send_message(
337
+ message.chat_id,
338
+ "App server unavailable; try again or check logs.",
339
+ thread_id=message.thread_id,
340
+ reply_to=message.message_id,
341
+ )
342
+ return None
343
+ if client is None:
344
+ await self._send_message(
345
+ message.chat_id,
346
+ "Topic not bound. Use /bind <repo_id> or /bind <path>.",
347
+ thread_id=message.thread_id,
348
+ reply_to=message.message_id,
349
+ )
350
+ return None
351
+ try:
352
+ result = await client.thread_resume(thread_id)
353
+ except Exception as exc:
354
+ log_event(
355
+ self._logger,
356
+ logging.WARNING,
357
+ "telegram.thread.verify_failed",
358
+ chat_id=message.chat_id,
359
+ thread_id=message.thread_id,
360
+ codex_thread_id=thread_id,
361
+ exc=exc,
362
+ )
363
+ await self._send_message(
364
+ message.chat_id,
365
+ "Failed to verify the active thread; use /resume or /new.",
366
+ thread_id=message.thread_id,
367
+ reply_to=message.message_id,
368
+ )
369
+ return None
370
+ info = _extract_thread_info(result)
371
+ resumed_path = info.get("workspace_path")
372
+ if not isinstance(resumed_path, str):
373
+ await self._send_message(
374
+ message.chat_id,
375
+ "Active thread missing workspace metadata; refusing to continue. "
376
+ "Fix the app-server workspace reporting and try /new.",
377
+ thread_id=message.thread_id,
378
+ reply_to=message.message_id,
379
+ )
380
+ return await self._router.set_active_thread(
381
+ message.chat_id, message.thread_id, None
382
+ )
383
+ try:
384
+ workspace_root = Path(record.workspace_path or "").expanduser().resolve()
385
+ resumed_root = Path(resumed_path).expanduser().resolve()
386
+ except Exception:
387
+ await self._send_message(
388
+ message.chat_id,
389
+ "Active thread has invalid workspace metadata; refusing to continue. "
390
+ "Fix the app-server workspace reporting and try /new.",
391
+ thread_id=message.thread_id,
392
+ reply_to=message.message_id,
393
+ )
394
+ return await self._router.set_active_thread(
395
+ message.chat_id, message.thread_id, None
396
+ )
397
+ if not _paths_compatible(workspace_root, resumed_root):
398
+ log_event(
399
+ self._logger,
400
+ logging.INFO,
401
+ "telegram.thread.workspace_mismatch",
402
+ chat_id=message.chat_id,
403
+ thread_id=message.thread_id,
404
+ codex_thread_id=thread_id,
405
+ workspace_path=str(workspace_root),
406
+ resumed_path=str(resumed_root),
407
+ )
408
+ await self._send_message(
409
+ message.chat_id,
410
+ "Active thread belongs to a different workspace; refusing to continue. "
411
+ "Fix the app-server workspace reporting and try /new.",
412
+ thread_id=message.thread_id,
413
+ reply_to=message.message_id,
414
+ )
415
+ return await self._router.set_active_thread(
416
+ message.chat_id, message.thread_id, None
417
+ )
418
+ return await self._apply_thread_result(
419
+ message.chat_id, message.thread_id, result, active_thread_id=thread_id
420
+ )
421
+
422
+ async def _find_thread_conflict(self, thread_id: str, *, key: str) -> Optional[str]:
423
+ return await self._store.find_active_thread(thread_id, exclude_key=key)
424
+
425
+ async def _handle_thread_conflict(
426
+ self,
427
+ message: TelegramMessage,
428
+ thread_id: str,
429
+ conflict_key: str,
430
+ ) -> None:
431
+ log_event(
432
+ self._logger,
433
+ logging.WARNING,
434
+ "telegram.thread.conflict",
435
+ chat_id=message.chat_id,
436
+ thread_id=message.thread_id,
437
+ codex_thread_id=thread_id,
438
+ conflict_topic=conflict_key,
439
+ )
440
+ await self._send_message(
441
+ message.chat_id,
442
+ "That Codex thread is already active in another topic. "
443
+ "Use /new here or continue in the other topic.",
444
+ thread_id=message.thread_id,
445
+ reply_to=message.message_id,
446
+ )
447
+
448
+ async def _apply_thread_result(
449
+ self,
450
+ chat_id: int,
451
+ thread_id: Optional[int],
452
+ result: Any,
453
+ *,
454
+ active_thread_id: Optional[str] = None,
455
+ overwrite_defaults: bool = False,
456
+ ) -> "TelegramTopicRecord":
457
+ info = _extract_thread_info(result)
458
+ if active_thread_id is None:
459
+ active_thread_id = info.get("thread_id")
460
+ user_preview, assistant_preview = _extract_thread_preview_parts(result)
461
+ last_used_at = now_iso()
462
+
463
+ def apply(record: "TelegramTopicRecord") -> None:
464
+ if active_thread_id:
465
+ record.active_thread_id = active_thread_id
466
+ if active_thread_id in record.thread_ids:
467
+ record.thread_ids.remove(active_thread_id)
468
+ record.thread_ids.insert(0, active_thread_id)
469
+ if len(record.thread_ids) > MAX_TOPIC_THREAD_HISTORY:
470
+ record.thread_ids = record.thread_ids[:MAX_TOPIC_THREAD_HISTORY]
471
+ _set_thread_summary(
472
+ record,
473
+ active_thread_id,
474
+ user_preview=user_preview,
475
+ assistant_preview=assistant_preview,
476
+ last_used_at=last_used_at,
477
+ workspace_path=info.get("workspace_path"),
478
+ rollout_path=info.get("rollout_path"),
479
+ )
480
+ incoming_workspace = info.get("workspace_path")
481
+ if isinstance(incoming_workspace, str) and incoming_workspace:
482
+ if record.workspace_path:
483
+ try:
484
+ current_root = canonicalize_path(Path(record.workspace_path))
485
+ incoming_root = canonicalize_path(Path(incoming_workspace))
486
+ except Exception:
487
+ current_root = None
488
+ incoming_root = None
489
+ if (
490
+ current_root is None
491
+ or incoming_root is None
492
+ or not _paths_compatible(current_root, incoming_root)
493
+ ):
494
+ log_event(
495
+ self._logger,
496
+ logging.WARNING,
497
+ "telegram.workspace.mismatch",
498
+ workspace_path=record.workspace_path,
499
+ incoming_workspace_path=incoming_workspace,
500
+ )
501
+ else:
502
+ record.workspace_path = incoming_workspace
503
+ else:
504
+ record.workspace_path = incoming_workspace
505
+ record.workspace_id = self._workspace_id_for_path(record.workspace_path)
506
+ if info.get("rollout_path"):
507
+ record.rollout_path = info["rollout_path"]
508
+ if info.get("agent") and (overwrite_defaults or record.agent is None):
509
+ normalized_agent = normalize_agent(info.get("agent"))
510
+ if normalized_agent:
511
+ record.agent = normalized_agent
512
+ if info.get("model") and (overwrite_defaults or record.model is None):
513
+ record.model = info["model"]
514
+ if info.get("effort") and (overwrite_defaults or record.effort is None):
515
+ record.effort = info["effort"]
516
+ if info.get("summary") and (overwrite_defaults or record.summary is None):
517
+ record.summary = info["summary"]
518
+ allow_thread_policies = record.approval_mode != APPROVAL_MODE_YOLO
519
+ if (
520
+ allow_thread_policies
521
+ and info.get("approval_policy")
522
+ and (overwrite_defaults or record.approval_policy is None)
523
+ ):
524
+ record.approval_policy = info["approval_policy"]
525
+ if (
526
+ allow_thread_policies
527
+ and info.get("sandbox_policy")
528
+ and (overwrite_defaults or record.sandbox_policy is None)
529
+ ):
530
+ record.sandbox_policy = info["sandbox_policy"]
531
+
532
+ return await self._router.update_topic(chat_id, thread_id, apply)
533
+
534
+ async def _require_bound_record(
535
+ self, message: TelegramMessage, *, prompt: Optional[str] = None
536
+ ) -> Optional["TelegramTopicRecord"]:
537
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
538
+ record = await self._router.get_topic(key)
539
+ if record is None or not record.workspace_path:
540
+ await self._send_message(
541
+ message.chat_id,
542
+ prompt or "Topic not bound. Use /bind <repo_id> or /bind <path>.",
543
+ thread_id=message.thread_id,
544
+ reply_to=message.message_id,
545
+ )
546
+ return None
547
+ await self._refresh_workspace_id(key, record)
548
+ return record
549
+
550
+ async def _ensure_thread_id(
551
+ self, message: TelegramMessage, record: "TelegramTopicRecord"
552
+ ) -> Optional[str]:
553
+ thread_id = record.active_thread_id
554
+ if thread_id:
555
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
556
+ conflict_key = await self._find_thread_conflict(thread_id, key=key)
557
+ if conflict_key:
558
+ await self._router.set_active_thread(
559
+ message.chat_id, message.thread_id, None
560
+ )
561
+ await self._handle_thread_conflict(message, thread_id, conflict_key)
562
+ return None
563
+ verified = await self._verify_active_thread(message, record)
564
+ if not verified:
565
+ return None
566
+ record = verified
567
+ thread_id = record.active_thread_id
568
+ if thread_id:
569
+ return thread_id
570
+ agent = self._effective_agent(record)
571
+ if agent == "opencode":
572
+ supervisor = getattr(self, "_opencode_supervisor", None)
573
+ if supervisor is None:
574
+ await self._send_message(
575
+ message.chat_id,
576
+ "OpenCode backend unavailable; install opencode or switch to /agent codex.",
577
+ thread_id=message.thread_id,
578
+ reply_to=message.message_id,
579
+ )
580
+ return None
581
+ workspace_root = self._canonical_workspace_root(record.workspace_path)
582
+ if workspace_root is None:
583
+ await self._send_message(
584
+ message.chat_id,
585
+ "Workspace unavailable.",
586
+ thread_id=message.thread_id,
587
+ reply_to=message.message_id,
588
+ )
589
+ return None
590
+ try:
591
+ opencode_client = await supervisor.get_client(workspace_root)
592
+ session = await opencode_client.create_session(
593
+ directory=str(workspace_root)
594
+ )
595
+ except Exception as exc:
596
+ log_event(
597
+ self._logger,
598
+ logging.WARNING,
599
+ "telegram.opencode.session.failed",
600
+ chat_id=message.chat_id,
601
+ thread_id=message.thread_id,
602
+ exc=exc,
603
+ )
604
+ await self._send_message(
605
+ message.chat_id,
606
+ "Failed to start a new OpenCode thread.",
607
+ thread_id=message.thread_id,
608
+ reply_to=message.message_id,
609
+ )
610
+ return None
611
+ session_id = extract_session_id(session, allow_fallback_id=True)
612
+ if not session_id:
613
+ await self._send_message(
614
+ message.chat_id,
615
+ "Failed to start a new OpenCode thread.",
616
+ thread_id=message.thread_id,
617
+ reply_to=message.message_id,
618
+ )
619
+ return None
620
+
621
+ def apply(record: "TelegramTopicRecord") -> None:
622
+ record.active_thread_id = session_id
623
+ if session_id in record.thread_ids:
624
+ record.thread_ids.remove(session_id)
625
+ record.thread_ids.insert(0, session_id)
626
+ if len(record.thread_ids) > MAX_TOPIC_THREAD_HISTORY:
627
+ record.thread_ids = record.thread_ids[:MAX_TOPIC_THREAD_HISTORY]
628
+ _set_thread_summary(
629
+ record,
630
+ session_id,
631
+ last_used_at=now_iso(),
632
+ workspace_path=record.workspace_path,
633
+ rollout_path=record.rollout_path,
634
+ )
635
+
636
+ await self._router.update_topic(message.chat_id, message.thread_id, apply)
637
+ return session_id
638
+ try:
639
+ client = await self._client_for_workspace(record.workspace_path)
640
+ except AppServerUnavailableError as exc:
641
+ log_event(
642
+ self._logger,
643
+ logging.WARNING,
644
+ "telegram.app_server.unavailable",
645
+ chat_id=message.chat_id,
646
+ thread_id=message.thread_id,
647
+ exc=exc,
648
+ )
649
+ await self._send_message(
650
+ message.chat_id,
651
+ "App server unavailable; try again or check logs.",
652
+ thread_id=message.thread_id,
653
+ reply_to=message.message_id,
654
+ )
655
+ return None
656
+ if client is None:
657
+ await self._send_message(
658
+ message.chat_id,
659
+ "Topic not bound. Use /bind <repo_id> or /bind <path>.",
660
+ thread_id=message.thread_id,
661
+ reply_to=message.message_id,
662
+ )
663
+ return None
664
+ thread = await client.thread_start(record.workspace_path or "", agent=agent)
665
+ if not await self._require_thread_workspace(
666
+ message, record.workspace_path, thread, action="thread_start"
667
+ ):
668
+ return None
669
+ thread_id = _extract_thread_id(thread)
670
+ if not thread_id:
671
+ await self._send_message(
672
+ message.chat_id,
673
+ "Failed to start a new thread.",
674
+ thread_id=message.thread_id,
675
+ reply_to=message.message_id,
676
+ )
677
+ return None
678
+ await self._apply_thread_result(
679
+ message.chat_id,
680
+ message.thread_id,
681
+ thread,
682
+ active_thread_id=thread_id,
683
+ )
684
+ return thread_id
685
+
686
+ def _list_manifest_repos(self) -> list[str]:
687
+ if not self._manifest_path or not self._hub_root:
688
+ return []
689
+ try:
690
+ manifest = load_manifest(self._manifest_path, self._hub_root)
691
+ except Exception:
692
+ return []
693
+ repo_ids = [repo.id for repo in manifest.repos if repo.enabled]
694
+ return repo_ids
695
+
696
+ def _resolve_workspace(self, arg: str) -> Optional[tuple[str, Optional[str]]]:
697
+ arg = (arg or "").strip()
698
+ if not arg:
699
+ return None
700
+ if self._manifest_path and self._hub_root:
701
+ try:
702
+ manifest = load_manifest(self._manifest_path, self._hub_root)
703
+ repo = manifest.get(arg)
704
+ if repo:
705
+ workspace = canonicalize_path(self._hub_root / repo.path)
706
+ return str(workspace), repo.id
707
+ except Exception:
708
+ pass
709
+ path = Path(arg)
710
+ if not path.is_absolute():
711
+ path = canonicalize_path(self._config.root / path)
712
+ else:
713
+ try:
714
+ path = canonicalize_path(path)
715
+ except Exception:
716
+ return None
717
+ if path.exists():
718
+ return str(path), None
719
+ return None
720
+
721
+ async def _require_thread_workspace(
722
+ self,
723
+ message: TelegramMessage,
724
+ expected_workspace: Optional[str],
725
+ result: Any,
726
+ *,
727
+ action: str,
728
+ ) -> bool:
729
+ if not expected_workspace:
730
+ return True
731
+ info = _extract_thread_info(result)
732
+ incoming = info.get("workspace_path")
733
+ if not isinstance(incoming, str) or not incoming:
734
+ log_event(
735
+ self._logger,
736
+ logging.WARNING,
737
+ "telegram.thread.workspace_missing",
738
+ action=action,
739
+ expected_workspace=expected_workspace,
740
+ )
741
+ await self._send_message(
742
+ message.chat_id,
743
+ "App server did not return a workspace for this thread. "
744
+ "Refusing to continue; fix the app-server workspace reporting and "
745
+ "try /new.",
746
+ thread_id=message.thread_id,
747
+ reply_to=message.message_id,
748
+ )
749
+ return False
750
+ try:
751
+ expected_root = Path(expected_workspace).expanduser().resolve()
752
+ incoming_root = Path(incoming).expanduser().resolve()
753
+ except Exception:
754
+ expected_root = None
755
+ incoming_root = None
756
+ if (
757
+ expected_root is None
758
+ or incoming_root is None
759
+ or not _paths_compatible(expected_root, incoming_root)
760
+ ):
761
+ log_event(
762
+ self._logger,
763
+ logging.WARNING,
764
+ "telegram.thread.workspace_mismatch",
765
+ action=action,
766
+ expected_workspace=expected_workspace,
767
+ incoming_workspace=incoming,
768
+ )
769
+ await self._send_message(
770
+ message.chat_id,
771
+ "App server returned a thread for a different workspace. "
772
+ "Refusing to continue; fix the app-server workspace reporting and "
773
+ "try /new.",
774
+ thread_id=message.thread_id,
775
+ reply_to=message.message_id,
776
+ )
777
+ return False
778
+ return True
779
+
780
+ async def _handle_bind(self, message: TelegramMessage, args: str) -> None:
781
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
782
+ if not args:
783
+ options = self._list_manifest_repos()
784
+ if not options:
785
+ await self._send_message(
786
+ message.chat_id,
787
+ "Usage: /bind <repo_id> or /bind <path>.",
788
+ thread_id=message.thread_id,
789
+ reply_to=message.message_id,
790
+ )
791
+ return
792
+ items = [(repo_id, repo_id) for repo_id in options]
793
+ state = SelectionState(items=items)
794
+ keyboard = self._build_bind_keyboard(state)
795
+ self._bind_options[key] = state
796
+ self._touch_cache_timestamp("bind_options", key)
797
+ await self._send_message(
798
+ message.chat_id,
799
+ self._selection_prompt(BIND_PICKER_PROMPT, state),
800
+ thread_id=message.thread_id,
801
+ reply_to=message.message_id,
802
+ reply_markup=keyboard,
803
+ )
804
+ return
805
+ await self._bind_topic_with_arg(key, args, message)
806
+
807
+ async def _bind_topic_by_repo_id(
808
+ self,
809
+ key: str,
810
+ repo_id: str,
811
+ callback: Optional[TelegramCallbackQuery] = None,
812
+ ) -> None:
813
+ self._bind_options.pop(key, None)
814
+ resolved = self._resolve_workspace(repo_id)
815
+ if resolved is None:
816
+ await self._answer_callback(callback, "Repo not found")
817
+ await self._finalize_selection(key, callback, "Repo not found.")
818
+ return
819
+ workspace_path, resolved_repo_id = resolved
820
+ chat_id, thread_id = _split_topic_key(key)
821
+ scope = self._topic_scope_id(resolved_repo_id, workspace_path)
822
+ await self._router.set_topic_scope(chat_id, thread_id, scope)
823
+ await self._router.bind_topic(
824
+ chat_id,
825
+ thread_id,
826
+ workspace_path,
827
+ repo_id=resolved_repo_id,
828
+ scope=scope,
829
+ )
830
+ workspace_id = self._workspace_id_for_path(workspace_path)
831
+ if workspace_id:
832
+ await self._router.update_topic(
833
+ chat_id,
834
+ thread_id,
835
+ lambda record: setattr(record, "workspace_id", workspace_id),
836
+ scope=scope,
837
+ )
838
+ await self._answer_callback(callback, "Bound to repo")
839
+ await self._finalize_selection(
840
+ key,
841
+ callback,
842
+ f"Bound to {resolved_repo_id or workspace_path}.",
843
+ )
844
+
845
+ async def _bind_topic_with_arg(
846
+ self, key: str, arg: str, message: TelegramMessage
847
+ ) -> None:
848
+ self._bind_options.pop(key, None)
849
+ resolved = self._resolve_workspace(arg)
850
+ if resolved is None:
851
+ await self._send_message(
852
+ message.chat_id,
853
+ "Unknown repo or path. Use /bind <repo_id> or /bind <path>.",
854
+ thread_id=message.thread_id,
855
+ reply_to=message.message_id,
856
+ )
857
+ return
858
+ workspace_path, repo_id = resolved
859
+ scope = self._topic_scope_id(repo_id, workspace_path)
860
+ await self._router.set_topic_scope(message.chat_id, message.thread_id, scope)
861
+ await self._router.bind_topic(
862
+ message.chat_id,
863
+ message.thread_id,
864
+ workspace_path,
865
+ repo_id=repo_id,
866
+ scope=scope,
867
+ )
868
+ workspace_id = self._workspace_id_for_path(workspace_path)
869
+ if workspace_id:
870
+ await self._router.update_topic(
871
+ message.chat_id,
872
+ message.thread_id,
873
+ lambda record: setattr(record, "workspace_id", workspace_id),
874
+ scope=scope,
875
+ )
876
+ await self._send_message(
877
+ message.chat_id,
878
+ f"Bound to {repo_id or workspace_path}.",
879
+ thread_id=message.thread_id,
880
+ reply_to=message.message_id,
881
+ )
882
+
883
+ async def _handle_new(self, message: TelegramMessage) -> None:
884
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
885
+ record = await self._router.get_topic(key)
886
+ if record is None or not record.workspace_path:
887
+ await self._send_message(
888
+ message.chat_id,
889
+ "Topic not bound. Use /bind <repo_id> or /bind <path>.",
890
+ thread_id=message.thread_id,
891
+ reply_to=message.message_id,
892
+ )
893
+ return
894
+ agent = self._effective_agent(record)
895
+ if agent == "opencode":
896
+ supervisor = getattr(self, "_opencode_supervisor", None)
897
+ if supervisor is None:
898
+ await self._send_message(
899
+ message.chat_id,
900
+ "OpenCode backend unavailable; install opencode or switch to /agent codex.",
901
+ thread_id=message.thread_id,
902
+ reply_to=message.message_id,
903
+ )
904
+ return
905
+ workspace_root = self._canonical_workspace_root(record.workspace_path)
906
+ if workspace_root is None:
907
+ await self._send_message(
908
+ message.chat_id,
909
+ "Workspace unavailable.",
910
+ thread_id=message.thread_id,
911
+ reply_to=message.message_id,
912
+ )
913
+ return
914
+ try:
915
+ client = await supervisor.get_client(workspace_root)
916
+ session = await client.create_session(directory=str(workspace_root))
917
+ except Exception as exc:
918
+ log_event(
919
+ self._logger,
920
+ logging.WARNING,
921
+ "telegram.opencode.session.failed",
922
+ chat_id=message.chat_id,
923
+ thread_id=message.thread_id,
924
+ exc=exc,
925
+ )
926
+ await self._send_message(
927
+ message.chat_id,
928
+ "Failed to start a new OpenCode thread.",
929
+ thread_id=message.thread_id,
930
+ reply_to=message.message_id,
931
+ )
932
+ return
933
+ session_id = extract_session_id(session, allow_fallback_id=True)
934
+ if not session_id:
935
+ await self._send_message(
936
+ message.chat_id,
937
+ "Failed to start a new OpenCode thread.",
938
+ thread_id=message.thread_id,
939
+ reply_to=message.message_id,
940
+ )
941
+ return
942
+
943
+ def apply(record: "TelegramTopicRecord") -> None:
944
+ record.active_thread_id = session_id
945
+ if session_id in record.thread_ids:
946
+ record.thread_ids.remove(session_id)
947
+ record.thread_ids.insert(0, session_id)
948
+ if len(record.thread_ids) > MAX_TOPIC_THREAD_HISTORY:
949
+ record.thread_ids = record.thread_ids[:MAX_TOPIC_THREAD_HISTORY]
950
+ _set_thread_summary(
951
+ record,
952
+ session_id,
953
+ last_used_at=now_iso(),
954
+ workspace_path=record.workspace_path,
955
+ rollout_path=record.rollout_path,
956
+ )
957
+
958
+ await self._router.update_topic(message.chat_id, message.thread_id, apply)
959
+ thread_id = session_id
960
+ else:
961
+ try:
962
+ client = await self._client_for_workspace(record.workspace_path)
963
+ except AppServerUnavailableError as exc:
964
+ log_event(
965
+ self._logger,
966
+ logging.WARNING,
967
+ "telegram.app_server.unavailable",
968
+ chat_id=message.chat_id,
969
+ thread_id=message.thread_id,
970
+ exc=exc,
971
+ )
972
+ await self._send_message(
973
+ message.chat_id,
974
+ "App server unavailable; try again or check logs.",
975
+ thread_id=message.thread_id,
976
+ reply_to=message.message_id,
977
+ )
978
+ return
979
+ if client is None:
980
+ await self._send_message(
981
+ message.chat_id,
982
+ "Topic not bound. Use /bind <repo_id> or /bind <path>.",
983
+ thread_id=message.thread_id,
984
+ reply_to=message.message_id,
985
+ )
986
+ return
987
+ thread = await client.thread_start(record.workspace_path, agent=agent)
988
+ if not await self._require_thread_workspace(
989
+ message, record.workspace_path, thread, action="thread_start"
990
+ ):
991
+ return
992
+ thread_id = _extract_thread_id(thread)
993
+ if not thread_id:
994
+ await self._send_message(
995
+ message.chat_id,
996
+ "Failed to start a new thread.",
997
+ thread_id=message.thread_id,
998
+ reply_to=message.message_id,
999
+ )
1000
+ return
1001
+ await self._apply_thread_result(
1002
+ message.chat_id, message.thread_id, thread, active_thread_id=thread_id
1003
+ )
1004
+ effort_label = (
1005
+ record.effort or "default" if self._agent_supports_effort(agent) else "n/a"
1006
+ )
1007
+ await self._send_message(
1008
+ message.chat_id,
1009
+ "\n".join(
1010
+ [
1011
+ f"Started new thread {thread_id}.",
1012
+ f"Directory: {record.workspace_path or 'unbound'}",
1013
+ f"Agent: {agent}",
1014
+ f"Model: {record.model or 'default'}",
1015
+ f"Effort: {effort_label}",
1016
+ ]
1017
+ ),
1018
+ thread_id=message.thread_id,
1019
+ reply_to=message.message_id,
1020
+ )
1021
+
1022
+ async def _handle_opencode_resume(
1023
+ self,
1024
+ message: TelegramMessage,
1025
+ record: "TelegramTopicRecord",
1026
+ *,
1027
+ key: str,
1028
+ show_unscoped: bool,
1029
+ refresh: bool,
1030
+ ) -> None:
1031
+ if refresh:
1032
+ log_event(
1033
+ self._logger,
1034
+ logging.INFO,
1035
+ "telegram.opencode.resume.refresh_ignored",
1036
+ chat_id=message.chat_id,
1037
+ thread_id=message.thread_id,
1038
+ )
1039
+ local_thread_ids: list[str] = []
1040
+ local_previews: dict[str, str] = {}
1041
+ local_thread_topics: dict[str, set[str]] = {}
1042
+ store_state = None
1043
+ if show_unscoped:
1044
+ store_state = await self._store.load()
1045
+ (
1046
+ local_thread_ids,
1047
+ local_previews,
1048
+ local_thread_topics,
1049
+ ) = _local_workspace_threads(
1050
+ store_state, record.workspace_path, current_key=key
1051
+ )
1052
+ for thread_id in record.thread_ids:
1053
+ local_thread_topics.setdefault(thread_id, set()).add(key)
1054
+ if thread_id not in local_thread_ids:
1055
+ local_thread_ids.append(thread_id)
1056
+ cached_preview = _thread_summary_preview(record, thread_id)
1057
+ if cached_preview:
1058
+ local_previews.setdefault(thread_id, cached_preview)
1059
+ allowed_thread_ids: set[str] = set()
1060
+ for thread_id in local_thread_ids:
1061
+ if thread_id in record.thread_ids:
1062
+ allowed_thread_ids.add(thread_id)
1063
+ continue
1064
+ for topic_key in local_thread_topics.get(thread_id, set()):
1065
+ topic_record = (
1066
+ store_state.topics.get(topic_key) if store_state else None
1067
+ )
1068
+ if topic_record and topic_record.agent == "opencode":
1069
+ allowed_thread_ids.add(thread_id)
1070
+ break
1071
+ if allowed_thread_ids:
1072
+ local_thread_ids = [
1073
+ thread_id
1074
+ for thread_id in local_thread_ids
1075
+ if thread_id in allowed_thread_ids
1076
+ ]
1077
+ local_previews = {
1078
+ thread_id: preview
1079
+ for thread_id, preview in local_previews.items()
1080
+ if thread_id in allowed_thread_ids
1081
+ }
1082
+ else:
1083
+ local_thread_ids = []
1084
+ local_previews = {}
1085
+ else:
1086
+ for thread_id in record.thread_ids:
1087
+ local_thread_ids.append(thread_id)
1088
+ cached_preview = _thread_summary_preview(record, thread_id)
1089
+ if cached_preview:
1090
+ local_previews.setdefault(thread_id, cached_preview)
1091
+ if not local_thread_ids:
1092
+ await self._send_message(
1093
+ message.chat_id,
1094
+ _with_conversation_id(
1095
+ "No previous OpenCode threads found for this topic. "
1096
+ "Use /new to start one.",
1097
+ chat_id=message.chat_id,
1098
+ thread_id=message.thread_id,
1099
+ ),
1100
+ thread_id=message.thread_id,
1101
+ reply_to=message.message_id,
1102
+ )
1103
+ return
1104
+ items: list[tuple[str, str]] = []
1105
+ seen_ids: set[str] = set()
1106
+ for thread_id in local_thread_ids:
1107
+ if thread_id in seen_ids:
1108
+ continue
1109
+ seen_ids.add(thread_id)
1110
+ preview = local_previews.get(thread_id)
1111
+ label = _format_missing_thread_label(thread_id, preview)
1112
+ items.append((thread_id, label))
1113
+ state = SelectionState(items=items)
1114
+ keyboard = self._build_resume_keyboard(state)
1115
+ self._resume_options[key] = state
1116
+ self._touch_cache_timestamp("resume_options", key)
1117
+ await self._send_message(
1118
+ message.chat_id,
1119
+ self._selection_prompt(RESUME_PICKER_PROMPT, state),
1120
+ thread_id=message.thread_id,
1121
+ reply_to=message.message_id,
1122
+ reply_markup=keyboard,
1123
+ )
1124
+
1125
+ async def _handle_resume(self, message: TelegramMessage, args: str) -> None:
1126
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
1127
+ parsed_args = self._parse_resume_args(args)
1128
+ if await self._handle_resume_shortcuts(key, message, parsed_args):
1129
+ return
1130
+ record = await self._router.get_topic(key)
1131
+ record = await self._ensure_resume_record(message, record)
1132
+ if record is None:
1133
+ return
1134
+ if self._effective_agent(record) == "opencode":
1135
+ await self._handle_opencode_resume(
1136
+ message,
1137
+ record,
1138
+ key=key,
1139
+ show_unscoped=parsed_args.show_unscoped,
1140
+ refresh=parsed_args.refresh,
1141
+ )
1142
+ return
1143
+ client = await self._get_resume_client(message, record)
1144
+ if client is None:
1145
+ return
1146
+ thread_data = await self._gather_resume_threads(
1147
+ message,
1148
+ record,
1149
+ client,
1150
+ key=key,
1151
+ show_unscoped=parsed_args.show_unscoped,
1152
+ )
1153
+ if thread_data is None:
1154
+ return
1155
+ await self._render_resume_picker(
1156
+ message,
1157
+ record,
1158
+ key,
1159
+ parsed_args,
1160
+ thread_data,
1161
+ client,
1162
+ )
1163
+
1164
+ def _parse_resume_args(self, args: str) -> ResumeCommandArgs:
1165
+ """Parse /resume arguments into structured values."""
1166
+ argv = self._parse_command_args(args)
1167
+ trimmed = args.strip()
1168
+ show_unscoped = False
1169
+ refresh = False
1170
+ remaining: list[str] = []
1171
+ for arg in argv:
1172
+ lowered = arg.lower()
1173
+ if lowered in ("--all", "all", "--unscoped", "unscoped"):
1174
+ show_unscoped = True
1175
+ continue
1176
+ if lowered in ("--refresh", "refresh"):
1177
+ refresh = True
1178
+ continue
1179
+ remaining.append(arg)
1180
+ if argv:
1181
+ trimmed = " ".join(remaining).strip()
1182
+ return ResumeCommandArgs(
1183
+ trimmed=trimmed,
1184
+ remaining=remaining,
1185
+ show_unscoped=show_unscoped,
1186
+ refresh=refresh,
1187
+ )
1188
+
1189
+ async def _handle_resume_shortcuts(
1190
+ self, key: str, message: TelegramMessage, args: ResumeCommandArgs
1191
+ ) -> bool:
1192
+ """Handle numeric or explicit thread selections before listing threads."""
1193
+ trimmed = args.trimmed
1194
+ if trimmed.isdigit():
1195
+ state = self._resume_options.get(key)
1196
+ if state:
1197
+ page_items = _page_slice(state.items, state.page, DEFAULT_PAGE_SIZE)
1198
+ choice = int(trimmed)
1199
+ if 0 < choice <= len(page_items):
1200
+ thread_id = page_items[choice - 1][0]
1201
+ await self._resume_thread_by_id(key, thread_id)
1202
+ return True
1203
+ if trimmed and not trimmed.isdigit():
1204
+ if args.remaining and args.remaining[0].lower() in ("list", "ls"):
1205
+ return False
1206
+ await self._resume_thread_by_id(key, trimmed)
1207
+ return True
1208
+ return False
1209
+
1210
+ async def _ensure_resume_record(
1211
+ self, message: TelegramMessage, record: Optional["TelegramTopicRecord"]
1212
+ ) -> Optional["TelegramTopicRecord"]:
1213
+ """Validate resume preconditions and return the topic record."""
1214
+ if record is None or not record.workspace_path:
1215
+ await self._send_message(
1216
+ message.chat_id,
1217
+ "Topic not bound. Use /bind <repo_id> or /bind <path>.",
1218
+ thread_id=message.thread_id,
1219
+ reply_to=message.message_id,
1220
+ )
1221
+ return None
1222
+ agent = self._effective_agent(record)
1223
+ if not self._agent_supports_resume(agent):
1224
+ await self._send_message(
1225
+ message.chat_id,
1226
+ "Resume is only supported for the codex and opencode agents. Use /agent codex or /agent opencode to switch.",
1227
+ thread_id=message.thread_id,
1228
+ reply_to=message.message_id,
1229
+ )
1230
+ return None
1231
+ return record
1232
+
1233
+ async def _get_resume_client(
1234
+ self, message: TelegramMessage, record: "TelegramTopicRecord"
1235
+ ) -> Optional[CodexAppServerClient]:
1236
+ """Resolve the app server client for the topic workspace."""
1237
+ try:
1238
+ client = await self._client_for_workspace(record.workspace_path)
1239
+ except AppServerUnavailableError as exc:
1240
+ log_event(
1241
+ self._logger,
1242
+ logging.WARNING,
1243
+ "telegram.app_server.unavailable",
1244
+ chat_id=message.chat_id,
1245
+ thread_id=message.thread_id,
1246
+ exc=exc,
1247
+ )
1248
+ await self._send_message(
1249
+ message.chat_id,
1250
+ "App server unavailable; try again or check logs.",
1251
+ thread_id=message.thread_id,
1252
+ reply_to=message.message_id,
1253
+ )
1254
+ return None
1255
+ if client is None:
1256
+ await self._send_message(
1257
+ message.chat_id,
1258
+ "Topic not bound. Use /bind <repo_id> or /bind <path>.",
1259
+ thread_id=message.thread_id,
1260
+ reply_to=message.message_id,
1261
+ )
1262
+ return None
1263
+ return client
1264
+
1265
+ async def _gather_resume_threads(
1266
+ self,
1267
+ message: TelegramMessage,
1268
+ record: "TelegramTopicRecord",
1269
+ client: CodexAppServerClient,
1270
+ *,
1271
+ key: str,
1272
+ show_unscoped: bool,
1273
+ ) -> Optional[ResumeThreadData]:
1274
+ """Collect local and remote threads for the resume picker."""
1275
+ if not show_unscoped and not record.thread_ids:
1276
+ await self._send_message(
1277
+ message.chat_id,
1278
+ "No previous threads found for this topic. Use /new to start one.",
1279
+ thread_id=message.thread_id,
1280
+ reply_to=message.message_id,
1281
+ )
1282
+ return None
1283
+ threads: list[dict[str, Any]] = []
1284
+ list_failed = False
1285
+ local_thread_ids: list[str] = []
1286
+ local_previews: dict[str, str] = {}
1287
+ local_thread_topics: dict[str, set[str]] = {}
1288
+ if show_unscoped:
1289
+ store_state = await self._store.load()
1290
+ (
1291
+ local_thread_ids,
1292
+ local_previews,
1293
+ local_thread_topics,
1294
+ ) = _local_workspace_threads(
1295
+ store_state, record.workspace_path, current_key=key
1296
+ )
1297
+ for thread_id in record.thread_ids:
1298
+ local_thread_topics.setdefault(thread_id, set()).add(key)
1299
+ if thread_id not in local_thread_ids:
1300
+ local_thread_ids.append(thread_id)
1301
+ cached_preview = _thread_summary_preview(record, thread_id)
1302
+ if cached_preview:
1303
+ local_previews.setdefault(thread_id, cached_preview)
1304
+ limit = _resume_thread_list_limit(record.thread_ids)
1305
+ needed_ids = (
1306
+ None if show_unscoped or not record.thread_ids else set(record.thread_ids)
1307
+ )
1308
+ try:
1309
+ threads, _ = await self._list_threads_paginated(
1310
+ client,
1311
+ limit=limit,
1312
+ max_pages=THREAD_LIST_MAX_PAGES,
1313
+ needed_ids=needed_ids,
1314
+ )
1315
+ except Exception as exc:
1316
+ list_failed = True
1317
+ log_event(
1318
+ self._logger,
1319
+ logging.WARNING,
1320
+ "telegram.resume.failed",
1321
+ chat_id=message.chat_id,
1322
+ thread_id=message.thread_id,
1323
+ exc=exc,
1324
+ )
1325
+ if show_unscoped and not local_thread_ids:
1326
+ await self._send_message(
1327
+ message.chat_id,
1328
+ _with_conversation_id(
1329
+ "Failed to list threads; check logs for details.",
1330
+ chat_id=message.chat_id,
1331
+ thread_id=message.thread_id,
1332
+ ),
1333
+ thread_id=message.thread_id,
1334
+ reply_to=message.message_id,
1335
+ )
1336
+ return None
1337
+ entries_by_id: dict[str, dict[str, Any]] = {}
1338
+ for entry in threads:
1339
+ if not isinstance(entry, dict):
1340
+ continue
1341
+ entry_id = entry.get("id")
1342
+ if isinstance(entry_id, str):
1343
+ entries_by_id[entry_id] = entry
1344
+ candidates: list[dict[str, Any]] = []
1345
+ unscoped_entries: list[dict[str, Any]] = []
1346
+ saw_path = False
1347
+ if show_unscoped:
1348
+ if threads:
1349
+ filtered, unscoped_entries, saw_path = _partition_threads(
1350
+ threads, record.workspace_path
1351
+ )
1352
+ seen_ids = {
1353
+ entry.get("id")
1354
+ for entry in filtered
1355
+ if isinstance(entry.get("id"), str)
1356
+ }
1357
+ candidates = filtered + [
1358
+ entry
1359
+ for entry in unscoped_entries
1360
+ if entry.get("id") not in seen_ids
1361
+ ]
1362
+ if not candidates and not local_thread_ids:
1363
+ if unscoped_entries and not saw_path:
1364
+ await self._send_message(
1365
+ message.chat_id,
1366
+ _with_conversation_id(
1367
+ "No workspace-tagged threads available. Use /resume --all to list "
1368
+ "unscoped threads.",
1369
+ chat_id=message.chat_id,
1370
+ thread_id=message.thread_id,
1371
+ ),
1372
+ thread_id=message.thread_id,
1373
+ reply_to=message.message_id,
1374
+ )
1375
+ return None
1376
+ await self._send_message(
1377
+ message.chat_id,
1378
+ _with_conversation_id(
1379
+ "No previous threads found for this workspace. "
1380
+ "If threads exist, update the app-server to include cwd metadata or use /new.",
1381
+ chat_id=message.chat_id,
1382
+ thread_id=message.thread_id,
1383
+ ),
1384
+ thread_id=message.thread_id,
1385
+ reply_to=message.message_id,
1386
+ )
1387
+ return None
1388
+ return ResumeThreadData(
1389
+ candidates=candidates,
1390
+ entries_by_id=entries_by_id,
1391
+ local_thread_ids=local_thread_ids,
1392
+ local_previews=local_previews,
1393
+ local_thread_topics=local_thread_topics,
1394
+ list_failed=list_failed,
1395
+ threads=threads,
1396
+ unscoped_entries=unscoped_entries,
1397
+ saw_path=saw_path,
1398
+ )
1399
+
1400
+ async def _render_resume_picker(
1401
+ self,
1402
+ message: TelegramMessage,
1403
+ record: "TelegramTopicRecord",
1404
+ key: str,
1405
+ args: ResumeCommandArgs,
1406
+ thread_data: ResumeThreadData,
1407
+ client: CodexAppServerClient,
1408
+ ) -> None:
1409
+ """Build and send the resume picker from gathered thread data."""
1410
+ entries_by_id = thread_data.entries_by_id
1411
+ local_thread_ids = thread_data.local_thread_ids
1412
+ local_previews = thread_data.local_previews
1413
+ local_thread_topics = thread_data.local_thread_topics
1414
+ missing_ids: list[str] = []
1415
+ if args.show_unscoped:
1416
+ for thread_id in local_thread_ids:
1417
+ if thread_id not in entries_by_id:
1418
+ missing_ids.append(thread_id)
1419
+ else:
1420
+ for thread_id in record.thread_ids:
1421
+ if thread_id not in entries_by_id:
1422
+ missing_ids.append(thread_id)
1423
+ if args.refresh and missing_ids:
1424
+ refreshed = await self._refresh_thread_summaries(
1425
+ client,
1426
+ missing_ids,
1427
+ topic_keys_by_thread=(
1428
+ local_thread_topics if args.show_unscoped else None
1429
+ ),
1430
+ default_topic_key=key,
1431
+ )
1432
+ if refreshed:
1433
+ if args.show_unscoped:
1434
+ store_state = await self._store.load()
1435
+ (
1436
+ local_thread_ids,
1437
+ local_previews,
1438
+ local_thread_topics,
1439
+ ) = _local_workspace_threads(
1440
+ store_state, record.workspace_path, current_key=key
1441
+ )
1442
+ for thread_id in record.thread_ids:
1443
+ local_thread_topics.setdefault(thread_id, set()).add(key)
1444
+ if thread_id not in local_thread_ids:
1445
+ local_thread_ids.append(thread_id)
1446
+ cached_preview = _thread_summary_preview(record, thread_id)
1447
+ if cached_preview:
1448
+ local_previews.setdefault(thread_id, cached_preview)
1449
+ else:
1450
+ record = await self._router.get_topic(key) or record
1451
+ items: list[tuple[str, str]] = []
1452
+ button_labels: dict[str, str] = {}
1453
+ seen_item_ids: set[str] = set()
1454
+ if args.show_unscoped:
1455
+ for entry in thread_data.candidates:
1456
+ candidate_id = entry.get("id")
1457
+ if not isinstance(candidate_id, str) or not candidate_id:
1458
+ continue
1459
+ if candidate_id in seen_item_ids:
1460
+ continue
1461
+ seen_item_ids.add(candidate_id)
1462
+ label = _format_thread_preview(entry)
1463
+ button_label = _extract_first_user_preview(entry)
1464
+ if button_label:
1465
+ button_labels[candidate_id] = button_label
1466
+ if label == "(no preview)":
1467
+ cached_preview = local_previews.get(candidate_id)
1468
+ if cached_preview:
1469
+ label = cached_preview
1470
+ items.append((candidate_id, label))
1471
+ for thread_id in local_thread_ids:
1472
+ if thread_id in seen_item_ids:
1473
+ continue
1474
+ seen_item_ids.add(thread_id)
1475
+ cached_preview = local_previews.get(thread_id)
1476
+ label = (
1477
+ cached_preview
1478
+ if cached_preview
1479
+ else _format_missing_thread_label(thread_id, None)
1480
+ )
1481
+ items.append((thread_id, label))
1482
+ else:
1483
+ if record.thread_ids:
1484
+ for thread_id in record.thread_ids:
1485
+ entry_data = entries_by_id.get(thread_id)
1486
+ if entry_data is None:
1487
+ cached_preview = _thread_summary_preview(record, thread_id)
1488
+ label = _format_missing_thread_label(thread_id, cached_preview)
1489
+ else:
1490
+ label = _format_thread_preview(entry_data)
1491
+ button_label = _extract_first_user_preview(entry_data)
1492
+ if button_label:
1493
+ button_labels[thread_id] = button_label
1494
+ if label == "(no preview)":
1495
+ cached_preview = _thread_summary_preview(record, thread_id)
1496
+ if cached_preview:
1497
+ label = cached_preview
1498
+ items.append((thread_id, label))
1499
+ else:
1500
+ for entry in entries_by_id.values():
1501
+ entry_id = entry.get("id")
1502
+ if not isinstance(entry_id, str) or not entry_id:
1503
+ continue
1504
+ label = _format_thread_preview(entry)
1505
+ button_label = _extract_first_user_preview(entry)
1506
+ if button_label:
1507
+ button_labels[entry_id] = button_label
1508
+ items.append((entry_id, label))
1509
+ if missing_ids:
1510
+ log_event(
1511
+ self._logger,
1512
+ logging.INFO,
1513
+ "telegram.resume.missing_thread_metadata",
1514
+ chat_id=message.chat_id,
1515
+ thread_id=message.thread_id,
1516
+ stored_count=len(record.thread_ids),
1517
+ listed_count=(
1518
+ len(entries_by_id)
1519
+ if not args.show_unscoped
1520
+ else len(thread_data.threads)
1521
+ ),
1522
+ missing_ids=missing_ids[:RESUME_MISSING_IDS_LOG_LIMIT],
1523
+ list_failed=thread_data.list_failed,
1524
+ )
1525
+ if not items:
1526
+ await self._send_message(
1527
+ message.chat_id,
1528
+ _with_conversation_id(
1529
+ "No resumable threads found.",
1530
+ chat_id=message.chat_id,
1531
+ thread_id=message.thread_id,
1532
+ ),
1533
+ thread_id=message.thread_id,
1534
+ reply_to=message.message_id,
1535
+ )
1536
+ return
1537
+ state = SelectionState(items=items, button_labels=button_labels)
1538
+ keyboard = self._build_resume_keyboard(state)
1539
+ self._resume_options[key] = state
1540
+ self._touch_cache_timestamp("resume_options", key)
1541
+ await self._send_message(
1542
+ message.chat_id,
1543
+ self._selection_prompt(RESUME_PICKER_PROMPT, state),
1544
+ thread_id=message.thread_id,
1545
+ reply_to=message.message_id,
1546
+ reply_markup=keyboard,
1547
+ )
1548
+
1549
+ async def _refresh_thread_summaries(
1550
+ self,
1551
+ client: CodexAppServerClient,
1552
+ thread_ids: Sequence[str],
1553
+ *,
1554
+ topic_keys_by_thread: Optional[dict[str, set[str]]] = None,
1555
+ default_topic_key: Optional[str] = None,
1556
+ ) -> set[str]:
1557
+ refreshed: set[str] = set()
1558
+ if not thread_ids:
1559
+ return refreshed
1560
+ unique_ids: list[str] = []
1561
+ seen: set[str] = set()
1562
+ for thread_id in thread_ids:
1563
+ if not isinstance(thread_id, str) or not thread_id:
1564
+ continue
1565
+ if thread_id in seen:
1566
+ continue
1567
+ seen.add(thread_id)
1568
+ unique_ids.append(thread_id)
1569
+ if len(unique_ids) >= RESUME_REFRESH_LIMIT:
1570
+ break
1571
+ for thread_id in unique_ids:
1572
+ try:
1573
+ result = await client.thread_resume(thread_id)
1574
+ except Exception as exc:
1575
+ log_event(
1576
+ self._logger,
1577
+ logging.WARNING,
1578
+ "telegram.resume.refresh_failed",
1579
+ thread_id=thread_id,
1580
+ exc=exc,
1581
+ )
1582
+ continue
1583
+ user_preview, assistant_preview = _extract_thread_preview_parts(result)
1584
+ info = _extract_thread_info(result)
1585
+ workspace_path = info.get("workspace_path")
1586
+ rollout_path = info.get("rollout_path")
1587
+ if (
1588
+ user_preview is None
1589
+ and assistant_preview is None
1590
+ and workspace_path is None
1591
+ and rollout_path is None
1592
+ ):
1593
+ continue
1594
+ last_used_at = now_iso() if user_preview or assistant_preview else None
1595
+
1596
+ def apply(
1597
+ record: TelegramTopicRecord,
1598
+ *,
1599
+ thread_id: str = thread_id,
1600
+ user_preview: Optional[str] = user_preview,
1601
+ assistant_preview: Optional[str] = assistant_preview,
1602
+ last_used_at: Optional[str] = last_used_at,
1603
+ workspace_path: Optional[str] = workspace_path,
1604
+ rollout_path: Optional[str] = rollout_path,
1605
+ ) -> None:
1606
+ _set_thread_summary(
1607
+ record,
1608
+ thread_id,
1609
+ user_preview=user_preview,
1610
+ assistant_preview=assistant_preview,
1611
+ last_used_at=last_used_at,
1612
+ workspace_path=workspace_path,
1613
+ rollout_path=rollout_path,
1614
+ )
1615
+
1616
+ keys = (
1617
+ topic_keys_by_thread.get(thread_id)
1618
+ if topic_keys_by_thread is not None
1619
+ else None
1620
+ )
1621
+ if keys:
1622
+ for key in keys:
1623
+ await self._store.update_topic(key, apply)
1624
+ elif default_topic_key:
1625
+ await self._store.update_topic(default_topic_key, apply)
1626
+ else:
1627
+ continue
1628
+ refreshed.add(thread_id)
1629
+ return refreshed
1630
+
1631
+ async def _list_threads_paginated(
1632
+ self,
1633
+ client: CodexAppServerClient,
1634
+ *,
1635
+ limit: int,
1636
+ max_pages: int,
1637
+ needed_ids: Optional[set[str]] = None,
1638
+ ) -> tuple[list[dict[str, Any]], set[str]]:
1639
+ entries: list[dict[str, Any]] = []
1640
+ found_ids: set[str] = set()
1641
+ seen_ids: set[str] = set()
1642
+ cursor: Optional[str] = None
1643
+ page_count = max(1, max_pages)
1644
+ for _ in range(page_count):
1645
+ payload = await client.thread_list(cursor=cursor, limit=limit)
1646
+ page_entries = _coerce_thread_list(payload)
1647
+ for entry in page_entries:
1648
+ if not isinstance(entry, dict):
1649
+ continue
1650
+ thread_id = entry.get("id")
1651
+ if isinstance(thread_id, str):
1652
+ if thread_id in seen_ids:
1653
+ continue
1654
+ seen_ids.add(thread_id)
1655
+ found_ids.add(thread_id)
1656
+ entries.append(entry)
1657
+ if needed_ids is not None and needed_ids.issubset(found_ids):
1658
+ break
1659
+ cursor = _extract_thread_list_cursor(payload)
1660
+ if not cursor:
1661
+ break
1662
+ return entries, found_ids
1663
+
1664
+ async def _resume_thread_by_id(
1665
+ self,
1666
+ key: str,
1667
+ thread_id: str,
1668
+ callback: Optional[TelegramCallbackQuery] = None,
1669
+ ) -> None:
1670
+ chat_id, thread_id_val = _split_topic_key(key)
1671
+ self._resume_options.pop(key, None)
1672
+ record = await self._router.get_topic(key)
1673
+ if record is not None and self._effective_agent(record) == "opencode":
1674
+ await self._resume_opencode_thread_by_id(key, thread_id, callback=callback)
1675
+ return
1676
+ if record is None or not record.workspace_path:
1677
+ await self._answer_callback(callback, "Resume aborted")
1678
+ await self._finalize_selection(
1679
+ key,
1680
+ callback,
1681
+ _with_conversation_id(
1682
+ "Topic not bound; use /bind before resuming.",
1683
+ chat_id=chat_id,
1684
+ thread_id=thread_id_val,
1685
+ ),
1686
+ )
1687
+ return
1688
+ try:
1689
+ client = await self._client_for_workspace(record.workspace_path)
1690
+ except AppServerUnavailableError as exc:
1691
+ log_event(
1692
+ self._logger,
1693
+ logging.WARNING,
1694
+ "telegram.app_server.unavailable",
1695
+ chat_id=chat_id,
1696
+ thread_id=thread_id_val,
1697
+ exc=exc,
1698
+ )
1699
+ await self._answer_callback(callback, "Resume aborted")
1700
+ await self._finalize_selection(
1701
+ key,
1702
+ callback,
1703
+ _with_conversation_id(
1704
+ "App server unavailable; try again or check logs.",
1705
+ chat_id=chat_id,
1706
+ thread_id=thread_id_val,
1707
+ ),
1708
+ )
1709
+ return
1710
+ if client is None:
1711
+ await self._answer_callback(callback, "Resume aborted")
1712
+ await self._finalize_selection(
1713
+ key,
1714
+ callback,
1715
+ _with_conversation_id(
1716
+ "Topic not bound; use /bind before resuming.",
1717
+ chat_id=chat_id,
1718
+ thread_id=thread_id_val,
1719
+ ),
1720
+ )
1721
+ return
1722
+ try:
1723
+ result = await client.thread_resume(thread_id)
1724
+ except Exception as exc:
1725
+ log_event(
1726
+ self._logger,
1727
+ logging.WARNING,
1728
+ "telegram.resume.failed",
1729
+ topic_key=key,
1730
+ thread_id=thread_id,
1731
+ exc=exc,
1732
+ )
1733
+ await self._answer_callback(callback, "Resume failed")
1734
+ chat_id, thread_id_val = _split_topic_key(key)
1735
+ await self._finalize_selection(
1736
+ key,
1737
+ callback,
1738
+ _with_conversation_id(
1739
+ "Failed to resume thread; check logs for details.",
1740
+ chat_id=chat_id,
1741
+ thread_id=thread_id_val,
1742
+ ),
1743
+ )
1744
+ return
1745
+ info = _extract_thread_info(result)
1746
+ resumed_path = info.get("workspace_path")
1747
+ if record is None or not record.workspace_path:
1748
+ await self._answer_callback(callback, "Resume aborted")
1749
+ await self._finalize_selection(
1750
+ key,
1751
+ callback,
1752
+ _with_conversation_id(
1753
+ "Topic not bound; use /bind before resuming.",
1754
+ chat_id=chat_id,
1755
+ thread_id=thread_id_val,
1756
+ ),
1757
+ )
1758
+ return
1759
+ if not isinstance(resumed_path, str):
1760
+ await self._answer_callback(callback, "Resume aborted")
1761
+ await self._finalize_selection(
1762
+ key,
1763
+ callback,
1764
+ _with_conversation_id(
1765
+ "Thread metadata missing workspace path; resume aborted to avoid cross-worktree mixups.",
1766
+ chat_id=chat_id,
1767
+ thread_id=thread_id_val,
1768
+ ),
1769
+ )
1770
+ return
1771
+ try:
1772
+ workspace_root = Path(record.workspace_path).expanduser().resolve()
1773
+ resumed_root = Path(resumed_path).expanduser().resolve()
1774
+ except Exception:
1775
+ await self._answer_callback(callback, "Resume aborted")
1776
+ await self._finalize_selection(
1777
+ key,
1778
+ callback,
1779
+ _with_conversation_id(
1780
+ "Thread workspace path is invalid; resume aborted.",
1781
+ chat_id=chat_id,
1782
+ thread_id=thread_id_val,
1783
+ ),
1784
+ )
1785
+ return
1786
+ if not _paths_compatible(workspace_root, resumed_root):
1787
+ await self._answer_callback(callback, "Resume aborted")
1788
+ await self._finalize_selection(
1789
+ key,
1790
+ callback,
1791
+ _with_conversation_id(
1792
+ "Thread belongs to a different workspace; resume aborted.",
1793
+ chat_id=chat_id,
1794
+ thread_id=thread_id_val,
1795
+ ),
1796
+ )
1797
+ return
1798
+ conflict_key = await self._find_thread_conflict(thread_id, key=key)
1799
+ if conflict_key:
1800
+ await self._answer_callback(callback, "Resume aborted")
1801
+ await self._finalize_selection(
1802
+ key,
1803
+ callback,
1804
+ _with_conversation_id(
1805
+ "Thread is already active in another topic; resume aborted.",
1806
+ chat_id=chat_id,
1807
+ thread_id=thread_id_val,
1808
+ ),
1809
+ )
1810
+ log_event(
1811
+ self._logger,
1812
+ logging.WARNING,
1813
+ "telegram.resume.conflict",
1814
+ topic_key=key,
1815
+ thread_id=thread_id,
1816
+ conflict_topic=conflict_key,
1817
+ )
1818
+ return
1819
+ updated_record = await self._apply_thread_result(
1820
+ chat_id,
1821
+ thread_id_val,
1822
+ result,
1823
+ active_thread_id=thread_id,
1824
+ overwrite_defaults=True,
1825
+ )
1826
+ await self._answer_callback(callback, "Resumed thread")
1827
+ message = _format_resume_summary(
1828
+ thread_id,
1829
+ result,
1830
+ workspace_path=updated_record.workspace_path,
1831
+ model=updated_record.model,
1832
+ effort=updated_record.effort,
1833
+ )
1834
+ await self._finalize_selection(key, callback, message)
1835
+
1836
+ async def _resume_opencode_thread_by_id(
1837
+ self,
1838
+ key: str,
1839
+ thread_id: str,
1840
+ callback: Optional[TelegramCallbackQuery] = None,
1841
+ ) -> None:
1842
+ chat_id, thread_id_val = _split_topic_key(key)
1843
+ self._resume_options.pop(key, None)
1844
+ record = await self._router.get_topic(key)
1845
+ if record is None or not record.workspace_path:
1846
+ await self._answer_callback(callback, "Resume aborted")
1847
+ await self._finalize_selection(
1848
+ key,
1849
+ callback,
1850
+ _with_conversation_id(
1851
+ "Topic not bound; use /bind before resuming.",
1852
+ chat_id=chat_id,
1853
+ thread_id=thread_id_val,
1854
+ ),
1855
+ )
1856
+ return
1857
+ supervisor = getattr(self, "_opencode_supervisor", None)
1858
+ if supervisor is None:
1859
+ await self._answer_callback(callback, "Resume aborted")
1860
+ await self._finalize_selection(
1861
+ key,
1862
+ callback,
1863
+ _with_conversation_id(
1864
+ "OpenCode backend unavailable; install opencode or switch to /agent codex.",
1865
+ chat_id=chat_id,
1866
+ thread_id=thread_id_val,
1867
+ ),
1868
+ )
1869
+ return
1870
+ workspace_root = self._canonical_workspace_root(record.workspace_path)
1871
+ if workspace_root is None:
1872
+ await self._answer_callback(callback, "Resume aborted")
1873
+ await self._finalize_selection(
1874
+ key,
1875
+ callback,
1876
+ _with_conversation_id(
1877
+ "Workspace unavailable; resume aborted.",
1878
+ chat_id=chat_id,
1879
+ thread_id=thread_id_val,
1880
+ ),
1881
+ )
1882
+ return
1883
+ try:
1884
+ client = await supervisor.get_client(workspace_root)
1885
+ session = await client.get_session(thread_id)
1886
+ except Exception as exc:
1887
+ log_event(
1888
+ self._logger,
1889
+ logging.WARNING,
1890
+ "telegram.opencode.resume.failed",
1891
+ topic_key=key,
1892
+ thread_id=thread_id,
1893
+ exc=exc,
1894
+ )
1895
+ await self._answer_callback(callback, "Resume failed")
1896
+ await self._finalize_selection(
1897
+ key,
1898
+ callback,
1899
+ _with_conversation_id(
1900
+ "Failed to resume OpenCode thread; check logs for details.",
1901
+ chat_id=chat_id,
1902
+ thread_id=thread_id_val,
1903
+ ),
1904
+ )
1905
+ return
1906
+ resumed_path = _extract_opencode_session_path(session)
1907
+ if resumed_path:
1908
+ try:
1909
+ workspace_root = Path(record.workspace_path).expanduser().resolve()
1910
+ resumed_root = Path(resumed_path).expanduser().resolve()
1911
+ except Exception:
1912
+ await self._answer_callback(callback, "Resume aborted")
1913
+ await self._finalize_selection(
1914
+ key,
1915
+ callback,
1916
+ _with_conversation_id(
1917
+ "Thread workspace path is invalid; resume aborted.",
1918
+ chat_id=chat_id,
1919
+ thread_id=thread_id_val,
1920
+ ),
1921
+ )
1922
+ return
1923
+ if not _paths_compatible(workspace_root, resumed_root):
1924
+ await self._answer_callback(callback, "Resume aborted")
1925
+ await self._finalize_selection(
1926
+ key,
1927
+ callback,
1928
+ _with_conversation_id(
1929
+ "Thread belongs to a different workspace; resume aborted.",
1930
+ chat_id=chat_id,
1931
+ thread_id=thread_id_val,
1932
+ ),
1933
+ )
1934
+ return
1935
+ conflict_key = await self._find_thread_conflict(thread_id, key=key)
1936
+ if conflict_key:
1937
+ await self._answer_callback(callback, "Resume aborted")
1938
+ await self._finalize_selection(
1939
+ key,
1940
+ callback,
1941
+ _with_conversation_id(
1942
+ "Thread is already active in another topic; resume aborted.",
1943
+ chat_id=chat_id,
1944
+ thread_id=thread_id_val,
1945
+ ),
1946
+ )
1947
+ log_event(
1948
+ self._logger,
1949
+ logging.WARNING,
1950
+ "telegram.resume.conflict",
1951
+ topic_key=key,
1952
+ thread_id=thread_id,
1953
+ conflict_topic=conflict_key,
1954
+ )
1955
+ return
1956
+
1957
+ def apply(record: "TelegramTopicRecord") -> None:
1958
+ record.active_thread_id = thread_id
1959
+ if thread_id in record.thread_ids:
1960
+ record.thread_ids.remove(thread_id)
1961
+ record.thread_ids.insert(0, thread_id)
1962
+ if len(record.thread_ids) > MAX_TOPIC_THREAD_HISTORY:
1963
+ record.thread_ids = record.thread_ids[:MAX_TOPIC_THREAD_HISTORY]
1964
+ _set_thread_summary(
1965
+ record,
1966
+ thread_id,
1967
+ last_used_at=now_iso(),
1968
+ workspace_path=record.workspace_path,
1969
+ rollout_path=record.rollout_path,
1970
+ )
1971
+
1972
+ updated_record = await self._router.update_topic(chat_id, thread_id_val, apply)
1973
+ await self._answer_callback(callback, "Resumed thread")
1974
+ summary = None
1975
+ if updated_record is not None:
1976
+ summary = updated_record.thread_summaries.get(thread_id)
1977
+ entry: dict[str, Any] = {}
1978
+ if summary is not None:
1979
+ entry = {
1980
+ "user_preview": summary.user_preview,
1981
+ "assistant_preview": summary.assistant_preview,
1982
+ }
1983
+ message = _format_resume_summary(
1984
+ thread_id,
1985
+ entry,
1986
+ workspace_path=updated_record.workspace_path if updated_record else None,
1987
+ model=updated_record.model if updated_record else None,
1988
+ effort=updated_record.effort if updated_record else None,
1989
+ )
1990
+ await self._finalize_selection(key, callback, message)
1991
+
1992
+ async def _handle_status(
1993
+ self, message: TelegramMessage, _args: str = "", runtime: Optional[Any] = None
1994
+ ) -> None:
1995
+ key = await self._resolve_topic_key(message.chat_id, message.thread_id)
1996
+ record = await self._router.ensure_topic(message.chat_id, message.thread_id)
1997
+ await self._refresh_workspace_id(key, record)
1998
+ if runtime is None:
1999
+ runtime = self._router.runtime_for(key)
2000
+ approval_policy, sandbox_policy = self._effective_policies(record)
2001
+ agent = self._effective_agent(record)
2002
+ effort_label = (
2003
+ record.effort or "default" if self._agent_supports_effort(agent) else "n/a"
2004
+ )
2005
+ lines = [
2006
+ f"Workspace: {record.workspace_path or 'unbound'}",
2007
+ f"Workspace ID: {record.workspace_id or 'unknown'}",
2008
+ f"Active thread: {record.active_thread_id or 'none'}",
2009
+ f"Active turn: {runtime.current_turn_id or 'none'}",
2010
+ f"Agent: {agent}",
2011
+ f"Resume: {'supported' if self._agent_supports_resume(agent) else 'unsupported'}",
2012
+ f"Model: {record.model or 'default'}",
2013
+ f"Effort: {effort_label}",
2014
+ f"Approval mode: {record.approval_mode}",
2015
+ f"Approval policy: {approval_policy or 'default'}",
2016
+ f"Sandbox policy: {_format_sandbox_policy(sandbox_policy)}",
2017
+ ]
2018
+ pending = await self._store.pending_approvals_for_key(key)
2019
+ if pending:
2020
+ lines.append(f"Pending approvals: {len(pending)}")
2021
+ if len(pending) == 1:
2022
+ age = _approval_age_seconds(pending[0].created_at)
2023
+ age_label = f"{age}s" if isinstance(age, int) else "unknown age"
2024
+ lines.append(f"Pending request: {pending[0].request_id} ({age_label})")
2025
+ else:
2026
+ preview = ", ".join(item.request_id for item in pending[:3])
2027
+ suffix = "" if len(pending) <= 3 else "..."
2028
+ lines.append(f"Pending requests: {preview}{suffix}")
2029
+ if record.summary:
2030
+ lines.append(f"Summary: {record.summary}")
2031
+ if record.active_thread_id:
2032
+ token_usage = self._token_usage_by_thread.get(record.active_thread_id)
2033
+ lines.extend(_format_token_usage(token_usage))
2034
+ rate_limits = await self._read_rate_limits(record.workspace_path, agent=agent)
2035
+ lines.extend(_format_rate_limits(rate_limits))
2036
+ if not record.workspace_path:
2037
+ lines.append("Use /bind <repo_id> or /bind <path>.")
2038
+ await self._send_message(
2039
+ message.chat_id,
2040
+ "\n".join(lines),
2041
+ thread_id=message.thread_id,
2042
+ reply_to=message.message_id,
2043
+ )