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,322 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from ...core.flows import FlowStore
10
+ from ...core.flows.controller import FlowController
11
+ from ...core.flows.models import FlowRunRecord, FlowRunStatus
12
+ from ...core.flows.worker_process import spawn_flow_worker
13
+ from ...core.utils import canonicalize_path
14
+ from ...flows.ticket_flow import build_ticket_flow_definition
15
+ from ...tickets import AgentPool
16
+ from .state import parse_topic_key
17
+
18
+
19
+ class TelegramTicketFlowBridge:
20
+ """Encapsulate ticket_flow pause/resume plumbing for Telegram service."""
21
+
22
+ def __init__(
23
+ self,
24
+ *,
25
+ logger: logging.Logger,
26
+ store,
27
+ pause_targets: dict[str, str],
28
+ send_message_with_outbox,
29
+ ) -> None:
30
+ self._logger = logger
31
+ self._store = store
32
+ self._pause_targets = pause_targets
33
+ self._send_message_with_outbox = send_message_with_outbox
34
+
35
+ @staticmethod
36
+ def _select_ticket_flow_topic(
37
+ entries: list[tuple[str, object]],
38
+ ) -> Optional[tuple[str, object]]:
39
+ if not entries:
40
+ return None
41
+
42
+ def score(entry: tuple[str, object]) -> tuple[int, float, str]:
43
+ key, record = entry
44
+ thread_id = None
45
+ try:
46
+ _chat_id, thread_id, _scope = parse_topic_key(key)
47
+ except Exception:
48
+ thread_id = None
49
+ active_raw = getattr(record, "active_thread_id", None)
50
+ try:
51
+ active_thread = int(active_raw) if active_raw is not None else None
52
+ except (TypeError, ValueError):
53
+ active_thread = None
54
+ active_match = (
55
+ int(thread_id) == active_thread if thread_id is not None else False
56
+ )
57
+ last_active_at = getattr(record, "last_active_at", None)
58
+ last_active = TelegramTicketFlowBridge._parse_last_active(last_active_at)
59
+ return (1 if active_match else 0, last_active, key)
60
+
61
+ return max(entries, key=score)
62
+
63
+ @staticmethod
64
+ def _parse_last_active(raw: Optional[str]) -> float:
65
+ if not isinstance(raw, str):
66
+ return float("-inf")
67
+ try:
68
+ return datetime.strptime(raw, "%Y-%m-%dT%H:%M:%SZ").timestamp()
69
+ except ValueError:
70
+ return float("-inf")
71
+
72
+ async def watch_ticket_flow_pauses(self, interval_seconds: float) -> None:
73
+ interval = max(interval_seconds, 1.0)
74
+ while True:
75
+ try:
76
+ await self._scan_and_notify_pauses()
77
+ except Exception as exc:
78
+ self._logger.warning("telegram.ticket_flow.watch_failed", exc_info=exc)
79
+ await asyncio.sleep(interval)
80
+
81
+ async def _scan_and_notify_pauses(self) -> None:
82
+ topics = await self._store.list_topics()
83
+ if not topics:
84
+ return
85
+ workspace_topics: dict[Path, list[tuple[str, object]]] = {}
86
+ for key, record in topics.items():
87
+ if not isinstance(record.workspace_path, str) or not record.workspace_path:
88
+ continue
89
+ workspace_root = canonicalize_path(Path(record.workspace_path))
90
+ workspace_topics.setdefault(workspace_root, []).append((key, record))
91
+
92
+ tasks = [
93
+ asyncio.create_task(self._notify_ticket_flow_pause(workspace_root, entries))
94
+ for workspace_root, entries in workspace_topics.items()
95
+ ]
96
+ if tasks:
97
+ await asyncio.gather(*tasks, return_exceptions=True)
98
+
99
+ async def _notify_ticket_flow_pause(
100
+ self,
101
+ workspace_root: Path,
102
+ entries: list[tuple[str, object]],
103
+ ) -> None:
104
+ try:
105
+ pause = await asyncio.to_thread(
106
+ self._load_ticket_flow_pause, workspace_root
107
+ )
108
+ except Exception as exc:
109
+ self._logger.warning(
110
+ "telegram.ticket_flow.scan_failed",
111
+ exc_info=exc,
112
+ workspace_root=str(workspace_root),
113
+ )
114
+ return
115
+ if pause is None:
116
+ return
117
+ run_id, seq, content = pause
118
+ marker = f"{run_id}:{seq}"
119
+ pending = [
120
+ (key, record)
121
+ for key, record in entries
122
+ if getattr(record, "last_ticket_dispatch_seq", None) != marker
123
+ ]
124
+ if not pending:
125
+ return
126
+ primary = self._select_ticket_flow_topic(pending)
127
+ if not primary:
128
+ return
129
+ message_text = self._format_ticket_flow_pause_message(run_id, seq, content)
130
+ updates: list[tuple[str, Optional[str]]] = [
131
+ (key, getattr(record, "last_ticket_dispatch_seq", None))
132
+ for key, record in pending
133
+ ]
134
+ for key, _previous in updates:
135
+ await self._store.update_topic(
136
+ key, self._set_ticket_dispatch_marker(marker)
137
+ )
138
+
139
+ primary_key, _primary_record = primary
140
+ try:
141
+ chat_id, thread_id, _scope = parse_topic_key(primary_key)
142
+ except Exception as exc:
143
+ self._logger.debug("Failed to parse topic key: %s", exc)
144
+ for key, previous in updates:
145
+ await self._store.update_topic(
146
+ key, self._set_ticket_dispatch_marker(previous)
147
+ )
148
+ return
149
+
150
+ try:
151
+ await self._send_message_with_outbox(
152
+ chat_id,
153
+ message_text,
154
+ thread_id=thread_id,
155
+ reply_to=None,
156
+ )
157
+ self._pause_targets[str(workspace_root)] = run_id
158
+ except Exception as exc:
159
+ self._logger.warning(
160
+ "telegram.ticket_flow.notify_failed",
161
+ exc_info=exc,
162
+ topic_key=primary_key,
163
+ run_id=run_id,
164
+ seq=seq,
165
+ )
166
+ for key, previous in updates:
167
+ await self._store.update_topic(
168
+ key, self._set_ticket_dispatch_marker(previous)
169
+ )
170
+
171
+ @staticmethod
172
+ def _set_ticket_dispatch_marker(
173
+ value: Optional[str],
174
+ ):
175
+ def apply(topic) -> None:
176
+ topic.last_ticket_dispatch_seq = value
177
+
178
+ return apply
179
+
180
+ def _load_ticket_flow_pause(
181
+ self, workspace_root: Path
182
+ ) -> Optional[tuple[str, str, str]]:
183
+ db_path = workspace_root / ".codex-autorunner" / "flows.db"
184
+ if not db_path.exists():
185
+ return None
186
+ store = FlowStore(db_path)
187
+ try:
188
+ store.initialize()
189
+ runs = store.list_flow_runs(
190
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
191
+ )
192
+ if not runs:
193
+ return None
194
+ latest = runs[0]
195
+ runs_dir_raw = latest.input_data.get("runs_dir")
196
+ runs_dir = (
197
+ Path(runs_dir_raw)
198
+ if isinstance(runs_dir_raw, str) and runs_dir_raw
199
+ else Path(".codex-autorunner/runs")
200
+ )
201
+ from ...tickets.outbox import resolve_outbox_paths
202
+
203
+ paths = resolve_outbox_paths(
204
+ workspace_root=workspace_root, runs_dir=runs_dir, run_id=latest.id
205
+ )
206
+ history_dir = paths.dispatch_history_dir
207
+ seq = self._latest_dispatch_seq(history_dir)
208
+ if not seq:
209
+ reason = self._format_ticket_flow_pause_reason(latest)
210
+ return latest.id, "paused", reason
211
+ message_path = history_dir / seq / "DISPATCH.md"
212
+ try:
213
+ content = message_path.read_text(encoding="utf-8")
214
+ except OSError:
215
+ return None
216
+ return latest.id, seq, content
217
+ finally:
218
+ store.close()
219
+
220
+ @staticmethod
221
+ def _latest_dispatch_seq(history_dir: Path) -> Optional[str]:
222
+ if not history_dir.exists() or not history_dir.is_dir():
223
+ return None
224
+ seqs = [
225
+ child.name
226
+ for child in history_dir.iterdir()
227
+ if child.is_dir()
228
+ and not child.name.startswith(".")
229
+ and child.name.isdigit()
230
+ ]
231
+ if not seqs:
232
+ return None
233
+ return max(seqs)
234
+
235
+ @staticmethod
236
+ def _format_ticket_flow_pause_reason(record: FlowRunRecord) -> str:
237
+ state = record.state or {}
238
+ engine = state.get("ticket_engine") or {}
239
+ reason = (
240
+ engine.get("reason") or record.error_message or "Paused without details."
241
+ )
242
+ return f"Reason: {reason}"
243
+
244
+ def _format_ticket_flow_pause_message(
245
+ self, run_id: str, seq: str, content: str
246
+ ) -> str:
247
+ from .helpers import _truncate_text
248
+
249
+ trimmed = _truncate_text(content.strip() or "(no dispatch message)", 3000)
250
+ return (
251
+ f"Ticket flow paused (run {run_id}). Latest dispatch #{seq}:\n\n"
252
+ f"{trimmed}\n\nUse /flow resume to continue."
253
+ )
254
+
255
+ def get_paused_ticket_flow(
256
+ self, workspace_root: Path, preferred_run_id: Optional[str] = None
257
+ ) -> Optional[tuple[str, FlowRunRecord]]:
258
+ db_path = workspace_root / ".codex-autorunner" / "flows.db"
259
+ if not db_path.exists():
260
+ return None
261
+ store = FlowStore(db_path)
262
+ try:
263
+ store.initialize()
264
+ if preferred_run_id:
265
+ preferred = store.get_flow_run(preferred_run_id)
266
+ if preferred and preferred.status == FlowRunStatus.PAUSED:
267
+ return preferred.id, preferred
268
+ runs = store.list_flow_runs(
269
+ flow_type="ticket_flow", status=FlowRunStatus.PAUSED
270
+ )
271
+ if not runs:
272
+ return None
273
+ latest = runs[0]
274
+ return latest.id, latest
275
+ finally:
276
+ store.close()
277
+
278
+ async def auto_resume_run(self, workspace_root: Path, run_id: str) -> None:
279
+ """Best-effort resume + worker spawn; failures are logged only."""
280
+ try:
281
+ controller = _ticket_controller_for(workspace_root)
282
+ updated = await controller.resume_flow(run_id)
283
+ if updated:
284
+ _spawn_ticket_worker(workspace_root, updated.id, self._logger)
285
+ except Exception as exc:
286
+ self._logger.warning(
287
+ "telegram.ticket_flow.auto_resume_failed",
288
+ exc=exc,
289
+ run_id=run_id,
290
+ workspace_root=str(workspace_root),
291
+ )
292
+
293
+
294
+ def _ticket_controller_for(repo_root: Path) -> FlowController:
295
+ repo_root = repo_root.resolve()
296
+ db_path = repo_root / ".codex-autorunner" / "flows.db"
297
+ artifacts_root = repo_root / ".codex-autorunner" / "flows"
298
+ from ...core.engine import Engine
299
+
300
+ engine = Engine(repo_root)
301
+ agent_pool = AgentPool(engine.config)
302
+ definition = build_ticket_flow_definition(agent_pool=agent_pool)
303
+ definition.validate()
304
+ controller = FlowController(
305
+ definition=definition, db_path=db_path, artifacts_root=artifacts_root
306
+ )
307
+ controller.initialize()
308
+ return controller
309
+
310
+
311
+ def _spawn_ticket_worker(repo_root: Path, run_id: str, logger: logging.Logger) -> None:
312
+ try:
313
+ proc, out, err = spawn_flow_worker(repo_root, run_id)
314
+ out.close()
315
+ err.close()
316
+ logger.info("Started ticket_flow worker for %s (pid=%s)", run_id, proc.pid)
317
+ except Exception as exc:
318
+ logger.warning(
319
+ "ticket_flow.worker.spawn_failed",
320
+ exc_info=exc,
321
+ extra={"run_id": run_id},
322
+ )
@@ -41,14 +41,33 @@ class TelegramMessageTransport:
41
41
  message_id: int,
42
42
  text: str,
43
43
  *,
44
+ message_thread_id: Optional[int] = None,
44
45
  reply_markup: Optional[dict[str, Any]] = None,
45
46
  ) -> bool:
46
47
  try:
47
48
  payload_text, parse_mode = self._prepare_message(text)
49
+ if len(payload_text) > TELEGRAM_MAX_MESSAGE_LENGTH:
50
+ trimmed = trim_markdown_message(
51
+ payload_text,
52
+ max_len=TELEGRAM_MAX_MESSAGE_LENGTH,
53
+ render=(
54
+ _format_telegram_html
55
+ if parse_mode == "HTML"
56
+ else (
57
+ lambda v: (
58
+ _format_telegram_markdown(v, parse_mode)
59
+ if parse_mode in ("Markdown", "MarkdownV2")
60
+ else v
61
+ )
62
+ )
63
+ ),
64
+ )
65
+ payload_text = trimmed
48
66
  await self._bot.edit_message_text(
49
67
  chat_id,
50
68
  message_id,
51
69
  payload_text,
70
+ message_thread_id=message_thread_id,
52
71
  reply_markup=reply_markup,
53
72
  parse_mode=parse_mode,
54
73
  )
@@ -56,11 +75,17 @@ class TelegramMessageTransport:
56
75
  return False
57
76
  return True
58
77
 
59
- async def _delete_message(self, chat_id: int, message_id: Optional[int]) -> bool:
78
+ async def _delete_message(
79
+ self, chat_id: int, message_id: Optional[int], thread_id: Optional[int] = None
80
+ ) -> bool:
60
81
  if message_id is None:
61
82
  return False
62
83
  try:
63
- return bool(await self._bot.delete_message(chat_id, message_id))
84
+ return bool(
85
+ await self._bot.delete_message(
86
+ chat_id, message_id, message_thread_id=thread_id
87
+ )
88
+ )
64
89
  except Exception:
65
90
  return False
66
91
 
@@ -77,6 +102,7 @@ class TelegramMessageTransport:
77
102
  callback.chat_id,
78
103
  callback.message_id,
79
104
  text,
105
+ message_thread_id=callback.thread_id,
80
106
  reply_markup=reply_markup,
81
107
  )
82
108
 
@@ -283,7 +309,7 @@ class TelegramMessageTransport:
283
309
  message_thread_id=thread_id,
284
310
  reply_to_message_id=reply_to if idx == 0 else None,
285
311
  reply_markup=reply_markup if idx == 0 else None,
286
- parse_mode="HTML",
312
+ parse_mode=used_mode,
287
313
  )
288
314
  return
289
315
  if overflow_mode == "trim":
@@ -385,7 +411,13 @@ class TelegramMessageTransport:
385
411
  if callback is None:
386
412
  return
387
413
  try:
388
- await self._bot.answer_callback_query(callback.callback_id, text=text)
414
+ await self._bot.answer_callback_query(
415
+ callback.callback_id,
416
+ chat_id=callback.chat_id,
417
+ thread_id=callback.thread_id,
418
+ message_id=callback.message_id,
419
+ text=text,
420
+ )
389
421
  except Exception as exc:
390
422
  log_event(
391
423
  self._logger,
@@ -393,6 +425,7 @@ class TelegramMessageTransport:
393
425
  "telegram.answer_callback.failed",
394
426
  chat_id=callback.chat_id,
395
427
  thread_id=callback.thread_id,
428
+ message_id=callback.message_id,
396
429
  callback_id=callback.callback_id,
397
430
  exc=exc,
398
431
  )
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, Optional
4
+
5
+ from .adapter import TelegramMessage
6
+
7
+ TriggerMode = Literal["all", "mentions"]
8
+
9
+
10
+ def should_trigger_run(
11
+ message: TelegramMessage,
12
+ *,
13
+ text: str,
14
+ bot_username: Optional[str],
15
+ ) -> bool:
16
+ """Return True if this message should start a run in mentions-only mode.
17
+
18
+ This mirrors Takopi's "mentions" trigger mode semantics (subset):
19
+
20
+ - Always trigger in private chats.
21
+ - Trigger when the bot is explicitly mentioned: "@<bot_username>" anywhere in the text.
22
+ - Trigger when replying to a bot message (but ignore the common forum-topic
23
+ "implicit root reply" case where clients set reply_to_message_id == thread_id).
24
+ - Otherwise, do not trigger (commands and other explicit affordances are handled elsewhere).
25
+ """
26
+
27
+ if message.chat_type == "private":
28
+ return True
29
+
30
+ lowered = (text or "").lower()
31
+ if bot_username:
32
+ needle = f"@{bot_username}".lower()
33
+ if needle in lowered:
34
+ return True
35
+
36
+ implicit_topic_reply = (
37
+ message.thread_id is not None
38
+ and message.reply_to_message_id is not None
39
+ and message.reply_to_message_id == message.thread_id
40
+ )
41
+
42
+ if message.reply_to_is_bot and not implicit_topic_reply:
43
+ return True
44
+
45
+ if (
46
+ bot_username
47
+ and message.reply_to_username
48
+ and message.reply_to_username.lower() == bot_username.lower()
49
+ and not implicit_topic_reply
50
+ ):
51
+ return True
52
+
53
+ return False
@@ -2,8 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import dataclasses
5
- from dataclasses import dataclass
6
- from typing import Optional
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional, Union
7
7
 
8
8
  from ..app_server.client import ApprovalDecision
9
9
  from .helpers import ModelOption
@@ -22,6 +22,26 @@ class PendingApproval:
22
22
  future: asyncio.Future[ApprovalDecision]
23
23
 
24
24
 
25
+ @dataclass
26
+ class PendingQuestion:
27
+ request_id: str
28
+ turn_id: str
29
+ codex_thread_id: Optional[str]
30
+ chat_id: int
31
+ thread_id: Optional[int]
32
+ topic_key: Optional[str]
33
+ message_id: Optional[int]
34
+ created_at: str
35
+ question_index: int
36
+ prompt: str
37
+ options: list[str]
38
+ future: asyncio.Future[Union[list[int], str, None]]
39
+ multiple: bool = False
40
+ custom: bool = True
41
+ selected_indices: set[int] = field(default_factory=set)
42
+ awaiting_custom_input: bool = False
43
+
44
+
25
45
  @dataclass
26
46
  class TurnContext:
27
47
  topic_key: str
@@ -65,7 +65,7 @@ class TelegramVoiceManager:
65
65
  self._lock = asyncio.Lock()
66
66
 
67
67
  async def restore(self) -> None:
68
- records = self._store.list_pending_voice()
68
+ records = await self._store.list_pending_voice()
69
69
  if not records:
70
70
  return
71
71
  log_event(
@@ -80,7 +80,7 @@ class TelegramVoiceManager:
80
80
  while True:
81
81
  await asyncio.sleep(VOICE_RETRY_INTERVAL_SECONDS)
82
82
  try:
83
- records = self._store.list_pending_voice()
83
+ records = await self._store.list_pending_voice()
84
84
  if records:
85
85
  await self._flush(records)
86
86
  except Exception as exc:
@@ -92,7 +92,7 @@ class TelegramVoiceManager:
92
92
  )
93
93
 
94
94
  async def attempt(self, record_id: str) -> bool:
95
- record = self._store.get_pending_voice(record_id)
95
+ record = await self._store.get_pending_voice(record_id)
96
96
  if record is None:
97
97
  return False
98
98
  if not self._ready_for_attempt(record):
@@ -101,7 +101,7 @@ class TelegramVoiceManager:
101
101
  return False
102
102
  inflight_id = record.record_id
103
103
  try:
104
- current_record = self._store.get_pending_voice(record.record_id)
104
+ current_record = await self._store.get_pending_voice(record.record_id)
105
105
  if current_record is None:
106
106
  return False
107
107
  if not self._ready_for_attempt(current_record):
@@ -114,7 +114,7 @@ class TelegramVoiceManager:
114
114
  finally:
115
115
  await self._clear_inflight(inflight_id)
116
116
  if done:
117
- self._store.delete_pending_voice(record.record_id)
117
+ await self._store.delete_pending_voice(record.record_id)
118
118
  return done
119
119
 
120
120
  async def _flush(self, records: list[PendingVoiceRecord]) -> None:
@@ -155,7 +155,7 @@ class TelegramVoiceManager:
155
155
  await self._deliver_transcript(record, record.transcript_text)
156
156
  self._remove_voice_file(record)
157
157
  return True
158
- path = self._resolve_voice_download_path(record)
158
+ path = await self._resolve_voice_download_path(record)
159
159
  if path is None:
160
160
  data, file_path, file_size = await self._download_file(record.file_id)
161
161
  if file_size and file_size > max_bytes:
@@ -180,11 +180,10 @@ class TelegramVoiceManager:
180
180
  record.file_size = file_size
181
181
  else:
182
182
  record.file_size = len(data)
183
- self._store.update_pending_voice(record)
183
+ await self._store.update_pending_voice(record)
184
184
  data = path.read_bytes()
185
185
  try:
186
- result = await asyncio.to_thread(
187
- self._voice_service.transcribe,
186
+ result = await self._voice_service.transcribe_async(
188
187
  data,
189
188
  client="telegram",
190
189
  filename=record.file_name or path.name,
@@ -236,7 +235,7 @@ class TelegramVoiceManager:
236
235
  text_len=len(transcript),
237
236
  )
238
237
  record.transcript_text = combined
239
- self._store.update_pending_voice(record)
238
+ await self._store.update_pending_voice(record)
240
239
  await self._deliver_transcript(record, combined)
241
240
  self._remove_voice_file(record)
242
241
  return True
@@ -253,7 +252,7 @@ class TelegramVoiceManager:
253
252
  record.last_attempt_at = now_iso()
254
253
  delay = self._retry_delay(record.attempts, retry_after=retry_after)
255
254
  record.next_attempt_at = _format_future_time(delay)
256
- self._store.update_pending_voice(record)
255
+ await self._store.update_pending_voice(record)
257
256
  log_event(
258
257
  self._logger,
259
258
  logging.WARNING,
@@ -273,7 +272,7 @@ class TelegramVoiceManager:
273
272
  )
274
273
  if progress_id is not None:
275
274
  record.progress_message_id = progress_id
276
- self._store.update_pending_voice(record)
275
+ await self._store.update_pending_voice(record)
277
276
  if record.attempts >= VOICE_MAX_ATTEMPTS:
278
277
  await self._give_up(
279
278
  record,
@@ -295,7 +294,7 @@ class TelegramVoiceManager:
295
294
  reply_to=record.message_id,
296
295
  )
297
296
  self._remove_voice_file(record)
298
- self._store.delete_pending_voice(record.record_id)
297
+ await self._store.delete_pending_voice(record.record_id)
299
298
  log_event(
300
299
  self._logger,
301
300
  logging.WARNING,
@@ -337,7 +336,7 @@ class TelegramVoiceManager:
337
336
  delay += random.uniform(0, jitter)
338
337
  return delay
339
338
 
340
- def _resolve_voice_download_path(
339
+ async def _resolve_voice_download_path(
341
340
  self, record: PendingVoiceRecord
342
341
  ) -> Optional[Path]:
343
342
  if not record.download_path:
@@ -346,7 +345,7 @@ class TelegramVoiceManager:
346
345
  if path.exists():
347
346
  return path
348
347
  record.download_path = None
349
- self._store.update_pending_voice(record)
348
+ await self._store.update_pending_voice(record)
350
349
  return None
351
350
 
352
351
  def _persist_voice_payload(
@@ -7,6 +7,7 @@ from typing import Any, Dict, List, Optional, cast
7
7
  import yaml
8
8
 
9
9
  MANIFEST_VERSION = 2
10
+ MANIFEST_HEADER = "# GENERATED by CAR - DO NOT EDIT\n"
10
11
  _SAFE_REPO_ID_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
11
12
  _SANITIZE_REPO_ID_PATTERN = re.compile(r"[^A-Za-z0-9._-]+")
12
13
 
@@ -194,4 +195,5 @@ def save_manifest(manifest_path: Path, manifest: Manifest, hub_root: Path) -> No
194
195
  "repos": [repo.to_dict(hub_root) for repo in manifest.repos],
195
196
  }
196
197
  with manifest_path.open("w", encoding="utf-8") as f:
198
+ f.write(MANIFEST_HEADER)
197
199
  yaml.safe_dump(payload, f, sort_keys=False)
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ """Codex Autorunner plugin API metadata.
4
+
5
+ This module is intentionally small and stable. External plugins SHOULD depend
6
+ only on the public API in `codex_autorunner.api` + this version constant.
7
+
8
+ Notes:
9
+ - Backwards-incompatible changes to the plugin API MUST bump
10
+ `CAR_PLUGIN_API_VERSION`.
11
+ """
12
+
13
+ CAR_PLUGIN_API_VERSION = 1
14
+
15
+ # Entry point groups (Python packaging entry points).
16
+ #
17
+ # Plugins can publish new agent backends by defining an entry point:
18
+ #
19
+ # [project.entry-points."codex_autorunner.agent_backends"]
20
+ # myagent = "my_package.my_module:AGENT_BACKEND"
21
+ #
22
+ CAR_AGENT_ENTRYPOINT_GROUP = "codex_autorunner.agent_backends"