codex-autorunner 0.1.2__py3-none-any.whl → 1.1.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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,399 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Optional, cast
7
+
8
+ from ..agents.opencode.constants import DEFAULT_TICKET_MODEL
9
+ from ..agents.opencode.runtime import collect_opencode_output, split_model_id
10
+ from ..agents.opencode.supervisor import OpenCodeSupervisor
11
+ from ..core.config import RepoConfig
12
+ from ..core.flows.models import FlowEventType
13
+ from ..core.utils import build_opencode_supervisor
14
+ from ..integrations.app_server.client import CodexAppServerClient
15
+ from ..integrations.app_server.env import build_app_server_env
16
+ from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
17
+
18
+ _logger = logging.getLogger(__name__)
19
+
20
+ EmitEventFn = Callable[[FlowEventType, dict[str, Any]], None]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class AgentTurnRequest:
25
+ agent_id: str # "codex" | "opencode"
26
+ prompt: str
27
+ workspace_root: Path
28
+ conversation_id: Optional[str] = None
29
+ # Optional, agent-specific extras.
30
+ options: Optional[dict[str, Any]] = None
31
+ # Optional flow event emitter (for live streaming).
32
+ emit_event: Optional[EmitEventFn] = None
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class AgentTurnResult:
37
+ agent_id: str
38
+ conversation_id: str
39
+ turn_id: str
40
+ text: str
41
+ error: Optional[str] = None
42
+ raw: Optional[dict[str, Any]] = None
43
+
44
+
45
+ class AgentPool:
46
+ """Minimal agent execution facade.
47
+
48
+ The pool is intentionally small: it can run either the Codex app-server or
49
+ OpenCode server for a single prompt.
50
+ """
51
+
52
+ def __init__(self, config: RepoConfig):
53
+ self._config = config
54
+ self._app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None
55
+ self._opencode_supervisor: Optional[OpenCodeSupervisor] = None
56
+ self._active_emitters: dict[str, EmitEventFn] = {}
57
+
58
+ @staticmethod
59
+ def _extract_turn_id(params: Any) -> Optional[str]:
60
+ if not isinstance(params, dict):
61
+ return None
62
+ for key in ("turnId", "turn_id", "id"):
63
+ value = params.get(key)
64
+ if isinstance(value, str) and value:
65
+ return value
66
+ turn = params.get("turn")
67
+ if isinstance(turn, dict):
68
+ for key in ("turnId", "turn_id", "id"):
69
+ value = turn.get(key)
70
+ if isinstance(value, str) and value:
71
+ return value
72
+ item = params.get("item")
73
+ if isinstance(item, dict):
74
+ for key in ("turnId", "turn_id", "id"):
75
+ value = item.get(key)
76
+ if isinstance(value, str) and value:
77
+ return value
78
+ return None
79
+
80
+ async def _handle_app_server_notification(self, message: dict[str, Any]) -> None:
81
+ method = message.get("method")
82
+ params = message.get("params")
83
+ turn_id = self._extract_turn_id(params)
84
+ if not turn_id:
85
+ return
86
+ emitter = self._active_emitters.get(turn_id)
87
+ if emitter is None:
88
+ return
89
+
90
+ # Forward the raw app-server event for richer UI rendering (tools, files, commands, etc.)
91
+ try:
92
+ emitter(
93
+ FlowEventType.APP_SERVER_EVENT,
94
+ {"message": message, "turn_id": turn_id},
95
+ )
96
+ except Exception:
97
+ _logger.exception("Failed emitting app-server event for turn %s", turn_id)
98
+
99
+ if method in ("item/agentMessage/delta", "turn/streamDelta"):
100
+ delta = None
101
+ if isinstance(params, dict):
102
+ raw = params.get("delta") or params.get("text")
103
+ if isinstance(raw, str):
104
+ delta = raw
105
+ if delta:
106
+ emitter(
107
+ FlowEventType.AGENT_STREAM_DELTA,
108
+ {"delta": delta, "turn_id": turn_id, "method": method},
109
+ )
110
+
111
+ def _ensure_app_server_supervisor(self) -> WorkspaceAppServerSupervisor:
112
+ if self._app_server_supervisor is not None:
113
+ return self._app_server_supervisor
114
+
115
+ app_server_cfg = self._config.app_server
116
+ ticket_flow_cfg = cast(dict[str, Any], getattr(self._config, "ticket_flow", {}))
117
+ default_approval_decision = ticket_flow_cfg.get(
118
+ "default_approval_decision", "accept"
119
+ )
120
+
121
+ def _env_builder(
122
+ workspace_root: Path, workspace_id: str, state_dir: Path
123
+ ) -> dict[str, str]:
124
+ # env is deterministic and purely derived from workspace/state dirs.
125
+ return build_app_server_env(
126
+ command=app_server_cfg.command,
127
+ workspace_root=workspace_root,
128
+ state_dir=state_dir,
129
+ logger=logging.getLogger("codex_autorunner.app_server"),
130
+ event_prefix=f"tickets.{workspace_id}",
131
+ base_env=None,
132
+ )
133
+
134
+ # Default approval decision is "accept" to keep the loop KISS.
135
+ self._app_server_supervisor = WorkspaceAppServerSupervisor(
136
+ app_server_cfg.command,
137
+ state_root=app_server_cfg.state_root,
138
+ env_builder=_env_builder,
139
+ logger=logging.getLogger("codex_autorunner.app_server"),
140
+ notification_handler=self._handle_app_server_notification,
141
+ auto_restart=app_server_cfg.auto_restart,
142
+ max_handles=app_server_cfg.max_handles,
143
+ idle_ttl_seconds=app_server_cfg.idle_ttl_seconds,
144
+ request_timeout=app_server_cfg.request_timeout,
145
+ turn_stall_timeout_seconds=app_server_cfg.turn_stall_timeout_seconds,
146
+ turn_stall_poll_interval_seconds=app_server_cfg.turn_stall_poll_interval_seconds,
147
+ turn_stall_recovery_min_interval_seconds=app_server_cfg.turn_stall_recovery_min_interval_seconds,
148
+ max_message_bytes=app_server_cfg.client.max_message_bytes,
149
+ oversize_preview_bytes=app_server_cfg.client.oversize_preview_bytes,
150
+ max_oversize_drain_bytes=app_server_cfg.client.max_oversize_drain_bytes,
151
+ restart_backoff_initial_seconds=app_server_cfg.client.restart_backoff_initial_seconds,
152
+ restart_backoff_max_seconds=app_server_cfg.client.restart_backoff_max_seconds,
153
+ restart_backoff_jitter_ratio=app_server_cfg.client.restart_backoff_jitter_ratio,
154
+ default_approval_decision=default_approval_decision,
155
+ )
156
+ return self._app_server_supervisor
157
+
158
+ def _ensure_opencode_supervisor(self) -> OpenCodeSupervisor:
159
+ if self._opencode_supervisor is not None:
160
+ return self._opencode_supervisor
161
+
162
+ app_server_cfg = self._config.app_server
163
+ opencode_command = self._config.agent_serve_command("opencode")
164
+ opencode_binary = None
165
+ try:
166
+ opencode_binary = self._config.agent_binary("opencode")
167
+ except Exception:
168
+ opencode_binary = None
169
+
170
+ agent_cfg = self._config.agents.get("opencode")
171
+ subagent_models = agent_cfg.subagent_models if agent_cfg else None
172
+
173
+ supervisor = build_opencode_supervisor(
174
+ opencode_command=opencode_command,
175
+ opencode_binary=opencode_binary,
176
+ workspace_root=self._config.root,
177
+ logger=logging.getLogger("codex_autorunner.opencode"),
178
+ request_timeout=app_server_cfg.request_timeout,
179
+ max_handles=app_server_cfg.max_handles,
180
+ idle_ttl_seconds=app_server_cfg.idle_ttl_seconds,
181
+ session_stall_timeout_seconds=self._config.opencode.session_stall_timeout_seconds,
182
+ base_env=None,
183
+ subagent_models=subagent_models,
184
+ )
185
+ if supervisor is None:
186
+ raise RuntimeError(
187
+ "OpenCode supervisor unavailable (missing opencode command/binary)."
188
+ )
189
+ self._opencode_supervisor = cast(OpenCodeSupervisor, supervisor)
190
+ return self._opencode_supervisor
191
+
192
+ async def close(self) -> None:
193
+ if self._app_server_supervisor is not None:
194
+ try:
195
+ await self._app_server_supervisor.close_all()
196
+ except Exception:
197
+ _logger.exception("Failed closing app-server supervisor")
198
+ self._app_server_supervisor = None
199
+ if self._opencode_supervisor is not None:
200
+ try:
201
+ await self._opencode_supervisor.close_all()
202
+ except Exception:
203
+ _logger.exception("Failed closing opencode supervisor")
204
+ self._opencode_supervisor = None
205
+
206
+ async def run_turn(self, req: AgentTurnRequest) -> AgentTurnResult:
207
+ if req.agent_id == "codex":
208
+ return await self._run_codex_turn(req)
209
+ if req.agent_id == "opencode":
210
+ return await self._run_opencode_turn(req)
211
+ raise ValueError(f"Unsupported agent_id: {req.agent_id}")
212
+
213
+ async def _run_codex_turn(self, req: AgentTurnRequest) -> AgentTurnResult:
214
+ supervisor = self._ensure_app_server_supervisor()
215
+ handle = await supervisor.get_client(req.workspace_root)
216
+ client: CodexAppServerClient = handle
217
+
218
+ approval_mode = (
219
+ cast(dict[str, Any], getattr(self._config, "ticket_flow", {})).get(
220
+ "approval_mode", "yolo"
221
+ )
222
+ or "yolo"
223
+ ).strip()
224
+ approval_policy = "never" if approval_mode == "yolo" else "on-request"
225
+ sandbox = "danger-full-access" if approval_mode == "yolo" else "workspace-write"
226
+
227
+ thread_id = req.conversation_id
228
+ if thread_id:
229
+ await client.thread_resume(thread_id)
230
+ else:
231
+ thread = await client.thread_start(
232
+ cwd=str(req.workspace_root),
233
+ approvalPolicy=approval_policy,
234
+ sandbox=sandbox,
235
+ )
236
+ thread_id = thread.get("id") or thread.get("thread", {}).get("id")
237
+ if not thread_id:
238
+ raise RuntimeError("Codex thread_start returned no thread id")
239
+
240
+ _logger.info(
241
+ "Starting turn for thread %s with prompt length %d",
242
+ thread_id,
243
+ len(req.prompt),
244
+ )
245
+ # Extract model/reasoning from options if provided.
246
+ turn_kwargs: dict[str, Any] = {}
247
+ if req.options:
248
+ if req.options.get("model"):
249
+ turn_kwargs["model"] = req.options["model"]
250
+ if req.options.get("reasoning"):
251
+ turn_kwargs["effort"] = req.options["reasoning"]
252
+ turn_handle = await client.turn_start(thread_id, req.prompt, **turn_kwargs)
253
+ if req.emit_event is not None:
254
+ self._active_emitters[turn_handle.turn_id] = req.emit_event
255
+ try:
256
+ result = await turn_handle.wait()
257
+ finally:
258
+ if req.emit_event is not None:
259
+ self._active_emitters.pop(turn_handle.turn_id, None)
260
+ text = "\n\n".join(result.agent_messages or []).strip()
261
+ return AgentTurnResult(
262
+ agent_id=req.agent_id,
263
+ conversation_id=thread_id,
264
+ turn_id=result.turn_id,
265
+ text=text,
266
+ error=result.errors[0] if result.errors else None,
267
+ raw={
268
+ "status": result.status,
269
+ },
270
+ )
271
+
272
+ async def _run_opencode_turn(self, req: AgentTurnRequest) -> AgentTurnResult:
273
+ supervisor = self._ensure_opencode_supervisor()
274
+ handle = await supervisor.get_client(req.workspace_root)
275
+ client = handle
276
+ directory = str(req.workspace_root)
277
+
278
+ options = req.options if isinstance(req.options, dict) else {}
279
+ model_raw = options.get("model")
280
+ model_payload = None
281
+ if isinstance(model_raw, dict):
282
+ provider_id = model_raw.get("providerID") or model_raw.get("providerId")
283
+ model_id = model_raw.get("modelID") or model_raw.get("modelId")
284
+ if provider_id and model_id:
285
+ model_payload = {"providerID": provider_id, "modelID": model_id}
286
+ elif isinstance(model_raw, str) and model_raw.strip():
287
+ model_payload = split_model_id(model_raw.strip())
288
+ if model_payload is None:
289
+ model_payload = split_model_id(DEFAULT_TICKET_MODEL)
290
+
291
+ variant = None
292
+ reasoning_raw = options.get("reasoning")
293
+ if isinstance(reasoning_raw, str) and reasoning_raw.strip():
294
+ variant = reasoning_raw.strip()
295
+
296
+ session_id = req.conversation_id
297
+ if not session_id:
298
+ created = await client.create_session(title="ticket", directory=directory)
299
+ session_id = created.get("id") or created.get("session", {}).get("id")
300
+ if not session_id:
301
+ raise RuntimeError("OpenCode create_session returned no session id")
302
+
303
+ prompt_response = await client.prompt_async(
304
+ session_id, message=req.prompt, model=model_payload, variant=variant
305
+ )
306
+
307
+ import uuid
308
+
309
+ turn_id = str(
310
+ prompt_response.get("id") if isinstance(prompt_response, dict) else ""
311
+ )
312
+ if not turn_id:
313
+ turn_id = str(uuid.uuid4())
314
+ text_item_id = f"text-{turn_id}"
315
+
316
+ async def _part_handler(
317
+ part_type: str, part: dict[str, Any], delta: Optional[str]
318
+ ) -> None:
319
+ if req.emit_event is None:
320
+ return
321
+ if part_type == "text" and isinstance(delta, str) and delta:
322
+ req.emit_event(
323
+ FlowEventType.AGENT_STREAM_DELTA,
324
+ {"delta": delta, "turn_id": turn_id, "part_type": part_type},
325
+ )
326
+ # Also emit app-server event for summary view
327
+ message = {
328
+ "method": "outputDelta",
329
+ "params": {
330
+ "delta": delta,
331
+ "turnId": turn_id,
332
+ "itemId": text_item_id,
333
+ },
334
+ }
335
+ req.emit_event(
336
+ FlowEventType.APP_SERVER_EVENT,
337
+ {"message": message, "turn_id": turn_id},
338
+ )
339
+ elif part_type == "reasoning" and isinstance(delta, str) and delta:
340
+ # Emit reasoning as app-server event for summary view
341
+ # Use item/reasoning/summaryTextDelta for merging behavior
342
+ message = {
343
+ "method": "item/reasoning/summaryTextDelta",
344
+ "params": {
345
+ "delta": delta,
346
+ "turnId": turn_id,
347
+ "itemId": f"reasoning-{turn_id}",
348
+ },
349
+ }
350
+ req.emit_event(
351
+ FlowEventType.APP_SERVER_EVENT,
352
+ {"message": message, "turn_id": turn_id},
353
+ )
354
+ elif part_type == "usage":
355
+ req.emit_event(
356
+ FlowEventType.TOKEN_USAGE,
357
+ {"usage": part, "turn_id": turn_id},
358
+ )
359
+
360
+ output = await collect_opencode_output(
361
+ client,
362
+ session_id=session_id,
363
+ workspace_path=directory,
364
+ model_payload=model_payload,
365
+ part_handler=_part_handler if req.emit_event is not None else None,
366
+ )
367
+
368
+ if req.emit_event is not None and output.text:
369
+ # Emit item/completed for the full text to ensure final state is correct
370
+ message = {
371
+ "method": "item/completed",
372
+ "params": {
373
+ "item": {
374
+ "type": "agentMessage",
375
+ "text": output.text,
376
+ "id": text_item_id,
377
+ },
378
+ "turnId": turn_id,
379
+ },
380
+ }
381
+ req.emit_event(
382
+ FlowEventType.APP_SERVER_EVENT,
383
+ {"message": message, "turn_id": turn_id},
384
+ )
385
+
386
+ if output.error:
387
+ return AgentTurnResult(
388
+ agent_id=req.agent_id,
389
+ conversation_id=session_id,
390
+ turn_id=turn_id,
391
+ text=output.text,
392
+ error=output.error,
393
+ )
394
+ return AgentTurnResult(
395
+ agent_id=req.agent_id,
396
+ conversation_id=session_id,
397
+ turn_id=turn_id,
398
+ text=output.text,
399
+ )
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from .frontmatter import parse_markdown_frontmatter
8
+ from .lint import lint_ticket_frontmatter
9
+ from .models import TicketDoc, TicketFrontmatter
10
+
11
+ # Accept TICKET-###.md or TICKET-###<suffix>.md (suffix optional), case-insensitive.
12
+ _TICKET_NAME_RE = re.compile(r"^TICKET-(\d{3,})(?:[^/]*)\.md$", re.IGNORECASE)
13
+
14
+
15
+ def parse_ticket_index(name: str) -> Optional[int]:
16
+ match = _TICKET_NAME_RE.match(name)
17
+ if not match:
18
+ return None
19
+ try:
20
+ return int(match.group(1))
21
+ except ValueError:
22
+ return None
23
+
24
+
25
+ def list_ticket_paths(ticket_dir: Path) -> list[Path]:
26
+ if not ticket_dir.exists() or not ticket_dir.is_dir():
27
+ return []
28
+ tickets: list[tuple[int, Path]] = []
29
+ for path in ticket_dir.iterdir():
30
+ if not path.is_file():
31
+ continue
32
+ idx = parse_ticket_index(path.name)
33
+ if idx is None:
34
+ continue
35
+ tickets.append((idx, path))
36
+ tickets.sort(key=lambda pair: pair[0])
37
+ return [p for _, p in tickets]
38
+
39
+
40
+ def read_ticket(path: Path) -> tuple[Optional[TicketDoc], list[str]]:
41
+ """Read and validate a ticket file.
42
+
43
+ Returns (ticket_doc, lint_errors). When lint errors are present, ticket_doc will
44
+ be None.
45
+ """
46
+
47
+ try:
48
+ raw = path.read_text(encoding="utf-8")
49
+ except OSError as exc:
50
+ return None, [f"Failed to read ticket: {exc}"]
51
+
52
+ data, body = parse_markdown_frontmatter(raw)
53
+ idx = parse_ticket_index(path.name)
54
+ if idx is None:
55
+ return None, [
56
+ "Invalid ticket filename; expected TICKET-<number>[suffix].md (e.g. TICKET-001-foo.md)"
57
+ ]
58
+
59
+ frontmatter, errors = lint_ticket_frontmatter(data)
60
+ if errors:
61
+ return None, errors
62
+ assert frontmatter is not None
63
+ return TicketDoc(path=path, index=idx, frontmatter=frontmatter, body=body), []
64
+
65
+
66
+ def read_ticket_frontmatter(
67
+ path: Path,
68
+ ) -> tuple[Optional[TicketFrontmatter], list[str]]:
69
+ try:
70
+ raw = path.read_text(encoding="utf-8")
71
+ except OSError as exc:
72
+ return None, [f"Failed to read ticket: {exc}"]
73
+ data, _ = parse_markdown_frontmatter(raw)
74
+ frontmatter, errors = lint_ticket_frontmatter(data)
75
+ return frontmatter, errors
76
+
77
+
78
+ def ticket_is_done(path: Path) -> bool:
79
+ frontmatter, errors = read_ticket_frontmatter(path)
80
+ if errors or not frontmatter:
81
+ return False
82
+ return bool(frontmatter.done)
83
+
84
+
85
+ def safe_relpath(path: Path, root: Path) -> str:
86
+ try:
87
+ return str(path.relative_to(root))
88
+ except ValueError:
89
+ return str(path)
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any, Optional, Tuple
5
+
6
+ import yaml
7
+
8
+ _FRONTMATTER_START = re.compile(r"^---\s*$")
9
+ _FRONTMATTER_END = re.compile(r"^(---|\.\.\.)\s*$")
10
+
11
+
12
+ def split_markdown_frontmatter(text: str) -> Tuple[Optional[str], str]:
13
+ """Split YAML frontmatter from a markdown document.
14
+
15
+ Returns (frontmatter_yaml, body). If no frontmatter is present, frontmatter_yaml is None.
16
+ """
17
+
18
+ if not text:
19
+ return None, ""
20
+ lines = text.splitlines()
21
+ if not lines:
22
+ return None, ""
23
+ if not _FRONTMATTER_START.match(lines[0]):
24
+ return None, text
25
+
26
+ end_idx: Optional[int] = None
27
+ for i in range(1, len(lines)):
28
+ if _FRONTMATTER_END.match(lines[i]):
29
+ end_idx = i
30
+ break
31
+ if end_idx is None:
32
+ # Malformed frontmatter; treat as absent so callers can surface a lint error.
33
+ return None, text
34
+
35
+ fm_yaml = "\n".join(lines[1:end_idx])
36
+ body = "\n".join(lines[end_idx + 1 :])
37
+ if body and not body.startswith("\n"):
38
+ body = "\n" + body
39
+ return fm_yaml, body
40
+
41
+
42
+ def parse_yaml_frontmatter(fm_yaml: Optional[str]) -> dict[str, Any]:
43
+ if fm_yaml is None:
44
+ return {}
45
+ try:
46
+ loaded = yaml.safe_load(fm_yaml)
47
+ except yaml.YAMLError:
48
+ return {}
49
+ return loaded if isinstance(loaded, dict) else {}
50
+
51
+
52
+ def parse_markdown_frontmatter(text: str) -> tuple[dict[str, Any], str]:
53
+ fm_yaml, body = split_markdown_frontmatter(text)
54
+ data = parse_yaml_frontmatter(fm_yaml)
55
+ return data, body
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional, Tuple
4
+
5
+ from ..agents.registry import validate_agent_id
6
+ from .models import TicketFrontmatter
7
+
8
+
9
+ def _as_optional_str(value: Any) -> Optional[str]:
10
+ if isinstance(value, str):
11
+ cleaned = value.strip()
12
+ return cleaned or None
13
+ return None
14
+
15
+
16
+ def lint_ticket_frontmatter(
17
+ data: dict[str, Any],
18
+ ) -> Tuple[Optional[TicketFrontmatter], list[str]]:
19
+ """Validate and normalize ticket frontmatter.
20
+
21
+ Required keys:
22
+ - agent: string (or the special value "user")
23
+ - done: bool
24
+ """
25
+
26
+ errors: list[str] = []
27
+ if not isinstance(data, dict) or not data:
28
+ return None, ["Missing or invalid YAML frontmatter (expected a mapping)."]
29
+
30
+ extra = {k: v for k, v in data.items()}
31
+
32
+ agent_raw = data.get("agent")
33
+ agent = _as_optional_str(agent_raw)
34
+ if not agent:
35
+ errors.append("frontmatter.agent is required (e.g. 'codex' or 'opencode').")
36
+ else:
37
+ # Special built-in ticket handler.
38
+ if agent != "user":
39
+ try:
40
+ validate_agent_id(agent)
41
+ except ValueError as exc:
42
+ errors.append(f"frontmatter.agent is invalid: {exc}")
43
+
44
+ done_raw = data.get("done")
45
+ done: Optional[bool]
46
+ if isinstance(done_raw, bool):
47
+ done = done_raw
48
+ else:
49
+ done = None
50
+ errors.append("frontmatter.done is required and must be a boolean.")
51
+
52
+ title = _as_optional_str(data.get("title"))
53
+ goal = _as_optional_str(data.get("goal"))
54
+
55
+ # Optional model/reasoning overrides.
56
+ model = _as_optional_str(data.get("model"))
57
+ reasoning = _as_optional_str(data.get("reasoning"))
58
+
59
+ # Remove normalized keys from extra.
60
+ for key in ("agent", "done", "title", "goal", "model", "reasoning"):
61
+ extra.pop(key, None)
62
+
63
+ if errors:
64
+ return None, errors
65
+
66
+ assert agent is not None
67
+ assert done is not None
68
+ return (
69
+ TicketFrontmatter(
70
+ agent=agent,
71
+ done=done,
72
+ title=title,
73
+ goal=goal,
74
+ model=model,
75
+ reasoning=reasoning,
76
+ extra=extra,
77
+ ),
78
+ [],
79
+ )
80
+
81
+
82
+ def lint_dispatch_frontmatter(
83
+ data: dict[str, Any],
84
+ ) -> Tuple[dict[str, Any], list[str]]:
85
+ """Validate DISPATCH.md frontmatter.
86
+
87
+ Keys:
88
+ - mode: "notify" | "pause" | "turn_summary" (defaults to notify)
89
+ """
90
+
91
+ errors: list[str] = []
92
+ if not isinstance(data, dict):
93
+ return {}, ["Invalid YAML frontmatter (expected a mapping)."]
94
+
95
+ mode_raw = data.get("mode")
96
+ mode = mode_raw.strip().lower() if isinstance(mode_raw, str) else "notify"
97
+ if mode not in ("notify", "pause", "turn_summary"):
98
+ errors.append("frontmatter.mode must be 'notify', 'pause', or 'turn_summary'.")
99
+
100
+ normalized = dict(data)
101
+ normalized["mode"] = mode
102
+ return normalized, errors