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,598 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Any, AsyncGenerator, Dict, Optional
7
+
8
+ from ...agents.opencode.client import OpenCodeClient
9
+ from ...agents.opencode.events import SSEEvent
10
+ from ...agents.opencode.logging import OpenCodeEventFormatter
11
+ from ...agents.opencode.runtime import (
12
+ OpenCodeTurnOutput,
13
+ build_turn_id,
14
+ collect_opencode_output,
15
+ extract_session_id,
16
+ map_approval_policy_to_permission,
17
+ opencode_missing_env,
18
+ parse_message_response,
19
+ split_model_id,
20
+ )
21
+ from ...agents.opencode.supervisor import OpenCodeSupervisor
22
+ from ...core.ports.agent_backend import (
23
+ AgentBackend,
24
+ AgentEvent,
25
+ AgentEventType,
26
+ now_iso,
27
+ )
28
+ from ...core.ports.run_event import (
29
+ Completed,
30
+ Failed,
31
+ OutputDelta,
32
+ RunEvent,
33
+ RunNotice,
34
+ Started,
35
+ TokenUsage,
36
+ ToolCall,
37
+ )
38
+ from ...core.text_delta_coalescer import StreamingTextCoalescer
39
+
40
+ _logger = logging.getLogger(__name__)
41
+
42
+
43
+ class OpenCodeBackend(AgentBackend):
44
+ def __init__(
45
+ self,
46
+ *,
47
+ base_url: Optional[str] = None,
48
+ supervisor: Optional[OpenCodeSupervisor] = None,
49
+ workspace_root: Optional[Path] = None,
50
+ auth: Optional[tuple[str, str]] = None,
51
+ timeout: Optional[float] = None,
52
+ agent: Optional[str] = None,
53
+ model: Optional[str] = None,
54
+ reasoning: Optional[str] = None,
55
+ approval_policy: Optional[str] = None,
56
+ session_stall_timeout_seconds: Optional[float] = None,
57
+ logger: Optional[logging.Logger] = None,
58
+ ):
59
+ self._supervisor = supervisor
60
+ self._workspace_root = Path(workspace_root) if workspace_root else None
61
+ self._client: Optional[OpenCodeClient]
62
+ if base_url:
63
+ self._client = OpenCodeClient(
64
+ base_url=base_url,
65
+ auth=auth,
66
+ timeout=timeout,
67
+ logger=logger,
68
+ )
69
+ else:
70
+ self._client = None
71
+ self._agent = agent
72
+ self._model = model
73
+ self._reasoning = reasoning
74
+ self._approval_policy = approval_policy
75
+ self._session_stall_timeout_seconds = session_stall_timeout_seconds
76
+ self._logger = logger or _logger
77
+
78
+ self._session_id: Optional[str] = None
79
+ self._message_count: int = 0
80
+ self._final_messages: list[str] = []
81
+ self._last_turn_id: Optional[str] = None
82
+ self._last_token_total: Optional[dict[str, Any]] = None
83
+ self._event_formatter = OpenCodeEventFormatter()
84
+
85
+ def configure(
86
+ self,
87
+ *,
88
+ model: Optional[str],
89
+ reasoning: Optional[str],
90
+ approval_policy: Optional[str],
91
+ ) -> None:
92
+ self._model = model
93
+ self._reasoning = reasoning
94
+ self._approval_policy = approval_policy
95
+
96
+ async def start_session(self, target: dict, context: dict) -> str:
97
+ client = await self._ensure_client()
98
+ workspace_root = self._workspace_root or Path(context.get("workspace") or ".")
99
+ resume_session = context.get("session_id") or context.get("thread_id")
100
+ if isinstance(resume_session, str) and resume_session:
101
+ try:
102
+ await client.get_session(resume_session)
103
+ self._session_id = resume_session
104
+ except Exception:
105
+ self._session_id = None
106
+
107
+ if not self._session_id:
108
+ result = await client.create_session(
109
+ title=f"Flow session {self._message_count}",
110
+ directory=str(workspace_root),
111
+ )
112
+ self._session_id = extract_session_id(result, allow_fallback_id=True)
113
+
114
+ if not self._session_id:
115
+ raise RuntimeError("Failed to create OpenCode session: missing session ID")
116
+
117
+ _logger.info("Started OpenCode session: %s", self._session_id)
118
+
119
+ return self._session_id
120
+
121
+ async def run_turn(
122
+ self, session_id: str, message: str
123
+ ) -> AsyncGenerator[AgentEvent, None]:
124
+ client = await self._ensure_client()
125
+ if session_id:
126
+ self._session_id = session_id
127
+ if not self._session_id:
128
+ self._session_id = await self.start_session(target={}, context={})
129
+
130
+ _logger.info("Sending message to session %s", self._session_id)
131
+
132
+ yield AgentEvent.stream_delta(content=message, delta_type="user_message")
133
+
134
+ await client.send_message(
135
+ self._session_id,
136
+ message=message,
137
+ agent=self._agent,
138
+ model=split_model_id(self._model) if self._model else None,
139
+ )
140
+
141
+ self._message_count += 1
142
+ async for event in self._yield_events_until_completion():
143
+ yield event
144
+
145
+ async def run_turn_events(
146
+ self, session_id: str, message: str
147
+ ) -> AsyncGenerator[RunEvent, None]:
148
+ client = await self._ensure_client()
149
+ workspace_root = self._workspace_root or Path(".")
150
+
151
+ if session_id:
152
+ self._session_id = session_id
153
+ if not self._session_id:
154
+ self._session_id = await self.start_session(
155
+ target={},
156
+ context={"workspace": str(workspace_root)},
157
+ )
158
+
159
+ _logger.info("Running turn events on session %s", self._session_id)
160
+
161
+ self._last_turn_id = build_turn_id(self._session_id)
162
+
163
+ yield Started(timestamp=now_iso(), session_id=self._session_id)
164
+
165
+ yield OutputDelta(
166
+ timestamp=now_iso(), content=message, delta_type="user_message"
167
+ )
168
+
169
+ model_payload = split_model_id(self._model) if self._model else None
170
+ missing_env = await opencode_missing_env(
171
+ client, str(workspace_root), model_payload
172
+ )
173
+ if missing_env:
174
+ provider_id = model_payload.get("providerID") if model_payload else None
175
+ missing_label = ", ".join(missing_env)
176
+ yield Failed(
177
+ timestamp=now_iso(),
178
+ error_message=(
179
+ "OpenCode provider "
180
+ f"{provider_id or 'selected'} requires env vars: {missing_label}"
181
+ ),
182
+ )
183
+ return
184
+
185
+ permission_policy = map_approval_policy_to_permission(
186
+ self._approval_policy, default="allow"
187
+ )
188
+
189
+ event_queue: asyncio.Queue[RunEvent] = asyncio.Queue()
190
+ self._event_formatter.reset()
191
+ assistant_stream_coalescer = StreamingTextCoalescer()
192
+
193
+ async def _enqueue_lines(lines: list[str]) -> None:
194
+ for line in lines:
195
+ await event_queue.put(
196
+ OutputDelta(
197
+ timestamp=now_iso(), content=line, delta_type="log_line"
198
+ )
199
+ )
200
+
201
+ async def _part_handler(
202
+ part_type: str, part: dict[str, Any], delta_text: Optional[str]
203
+ ) -> None:
204
+ if part_type == "usage" and isinstance(part, dict):
205
+ self._last_token_total = _usage_to_token_total(part)
206
+ await event_queue.put(TokenUsage(timestamp=now_iso(), usage=dict(part)))
207
+ await _enqueue_lines(self._event_formatter.format_usage(part))
208
+ else:
209
+ await _enqueue_lines(
210
+ self._event_formatter.format_part(part_type, part, delta_text)
211
+ )
212
+ if part_type == "text" and isinstance(delta_text, str) and delta_text:
213
+ for chunk in assistant_stream_coalescer.add(delta_text):
214
+ await event_queue.put(
215
+ OutputDelta(
216
+ timestamp=now_iso(),
217
+ content=chunk,
218
+ delta_type="assistant_stream",
219
+ )
220
+ )
221
+
222
+ ready_event = asyncio.Event()
223
+ output_task = asyncio.create_task(
224
+ collect_opencode_output(
225
+ client,
226
+ session_id=self._session_id,
227
+ workspace_path=str(workspace_root),
228
+ model_payload=model_payload,
229
+ permission_policy=permission_policy,
230
+ part_handler=_part_handler,
231
+ ready_event=ready_event,
232
+ stall_timeout_seconds=self._session_stall_timeout_seconds,
233
+ )
234
+ )
235
+ try:
236
+ await asyncio.wait_for(ready_event.wait(), timeout=2.0)
237
+ except asyncio.TimeoutError:
238
+ await event_queue.put(
239
+ RunNotice(
240
+ timestamp=now_iso(),
241
+ kind="ready_timeout",
242
+ message="OpenCode stream readiness wait timed out",
243
+ data={"timeout_seconds": 2.0},
244
+ )
245
+ )
246
+
247
+ prompt_response: Any = None
248
+ prompt_task: Optional[asyncio.Task[Any]] = asyncio.create_task(
249
+ client.prompt_async(
250
+ self._session_id,
251
+ message=message,
252
+ agent=self._agent,
253
+ model=model_payload,
254
+ variant=self._reasoning,
255
+ )
256
+ )
257
+
258
+ output_result = None
259
+ try:
260
+ while True:
261
+ queue_task = asyncio.create_task(event_queue.get())
262
+ tasks = {output_task, queue_task}
263
+ if prompt_task is not None:
264
+ tasks.add(prompt_task)
265
+ done, pending = await asyncio.wait(
266
+ tasks, return_when=asyncio.FIRST_COMPLETED
267
+ )
268
+
269
+ if queue_task in done:
270
+ yield queue_task.result()
271
+ else:
272
+ queue_task.cancel()
273
+ with contextlib.suppress(asyncio.CancelledError):
274
+ await queue_task
275
+
276
+ if prompt_task is not None and prompt_task in done:
277
+ try:
278
+ prompt_response = prompt_task.result()
279
+ except Exception as exc:
280
+ output_task.cancel()
281
+ with contextlib.suppress(asyncio.CancelledError):
282
+ await output_task
283
+ yield Failed(timestamp=now_iso(), error_message=str(exc))
284
+ return
285
+ prompt_task = None
286
+
287
+ if output_task in done:
288
+ output_result = await output_task
289
+ break
290
+
291
+ finally:
292
+ if prompt_task is not None:
293
+ with contextlib.suppress(asyncio.CancelledError):
294
+ await prompt_task
295
+ for line in self._event_formatter.flush_all_reasoning():
296
+ await event_queue.put(
297
+ OutputDelta(
298
+ timestamp=now_iso(), content=line, delta_type="log_line"
299
+ )
300
+ )
301
+ for chunk in assistant_stream_coalescer.flush():
302
+ await event_queue.put(
303
+ OutputDelta(
304
+ timestamp=now_iso(),
305
+ content=chunk,
306
+ delta_type="assistant_stream",
307
+ )
308
+ )
309
+
310
+ while not event_queue.empty():
311
+ yield event_queue.get_nowait()
312
+
313
+ if output_result is None:
314
+ yield Failed(timestamp=now_iso(), error_message="OpenCode output failed")
315
+ return
316
+
317
+ if prompt_response is not None and not output_result.text:
318
+ fallback = parse_message_response(prompt_response)
319
+ if fallback.text:
320
+ output_result = OpenCodeTurnOutput(
321
+ text=fallback.text, error=output_result.error
322
+ )
323
+ if fallback.error and not output_result.error:
324
+ output_result = OpenCodeTurnOutput(
325
+ text=output_result.text, error=fallback.error
326
+ )
327
+
328
+ if output_result.text:
329
+ yield Completed(timestamp=now_iso(), final_message=output_result.text)
330
+ elif output_result.error:
331
+ yield Failed(timestamp=now_iso(), error_message=output_result.error)
332
+ else:
333
+ yield Completed(timestamp=now_iso(), final_message="")
334
+
335
+ async def stream_events(self, session_id: str) -> AsyncGenerator[AgentEvent, None]:
336
+ if session_id:
337
+ self._session_id = session_id
338
+ if not self._session_id:
339
+ raise RuntimeError("Session not started. Call start_session() first.")
340
+
341
+ client = await self._ensure_client()
342
+ async for sse in client.stream_events(directory=None):
343
+ for agent_event in self._convert_sse_to_agent_event(sse):
344
+ yield agent_event
345
+
346
+ async def interrupt(self, session_id: str) -> None:
347
+ target_session = session_id or self._session_id
348
+ if target_session:
349
+ client = await self._ensure_client()
350
+ try:
351
+ await client.abort(target_session)
352
+ _logger.info("Interrupted OpenCode session %s", target_session)
353
+ except Exception as e:
354
+ _logger.warning("Failed to interrupt session: %s", e)
355
+
356
+ async def final_messages(self, session_id: str) -> list[str]:
357
+ return self._final_messages
358
+
359
+ async def request_approval(
360
+ self, description: str, context: Optional[Dict[str, Any]] = None
361
+ ) -> bool:
362
+ raise NotImplementedError("Approvals not implemented for OpenCodeBackend")
363
+
364
+ async def _yield_events_until_completion(self) -> AsyncGenerator[AgentEvent, None]:
365
+ paths = ["/event", "/global/event"]
366
+ if self._session_id:
367
+ paths.insert(0, f"/session/{self._session_id}/event")
368
+ try:
369
+ client = await self._ensure_client()
370
+ async for sse in client.stream_events(
371
+ directory=None,
372
+ paths=paths,
373
+ ):
374
+ if not self._sse_matches_session(sse):
375
+ continue
376
+ for agent_event in self._convert_sse_to_agent_event(sse):
377
+ yield agent_event
378
+ if agent_event.event_type in {
379
+ AgentEventType.MESSAGE_COMPLETE,
380
+ AgentEventType.SESSION_ENDED,
381
+ }:
382
+ if agent_event.event_type == AgentEventType.MESSAGE_COMPLETE:
383
+ self._final_messages.append(
384
+ agent_event.data.get("final_message", "")
385
+ )
386
+ return
387
+ except Exception as e:
388
+ _logger.warning("Error in event collection: %s", e)
389
+ yield AgentEvent.error(error_message=str(e))
390
+
391
+ async def _yield_run_events_until_completion(
392
+ self,
393
+ ) -> AsyncGenerator[RunEvent, None]:
394
+ paths = ["/event", "/global/event"]
395
+ if self._session_id:
396
+ paths.insert(0, f"/session/{self._session_id}/event")
397
+ try:
398
+ client = await self._ensure_client()
399
+ async for sse in client.stream_events(
400
+ directory=None,
401
+ paths=paths,
402
+ ):
403
+ if not self._sse_matches_session(sse):
404
+ continue
405
+ for run_event in self._convert_sse_to_run_event(sse):
406
+ yield run_event
407
+ if isinstance(run_event, (Completed, Failed)):
408
+ if isinstance(run_event, Completed):
409
+ self._final_messages.append(run_event.final_message)
410
+ return
411
+ except Exception as e:
412
+ _logger.warning("Error in run event collection: %s", e)
413
+ yield Failed(timestamp=now_iso(), error_message=str(e))
414
+
415
+ def _convert_sse_to_run_event(self, sse: SSEEvent) -> list[RunEvent]:
416
+ events: list[RunEvent] = []
417
+
418
+ try:
419
+ payload = json.loads(sse.data) if sse.data else {}
420
+ except json.JSONDecodeError:
421
+ return events
422
+
423
+ payload_type = payload.get("type", "")
424
+
425
+ if payload_type == "textDelta":
426
+ text = payload.get("text", "")
427
+ events.append(
428
+ OutputDelta(
429
+ timestamp=now_iso(), content=text, delta_type="assistant_stream"
430
+ )
431
+ )
432
+
433
+ elif payload_type == "toolCall":
434
+ tool_name = payload.get("toolName", "")
435
+ tool_input = payload.get("toolInput", {})
436
+ events.append(
437
+ ToolCall(
438
+ timestamp=now_iso(), tool_name=tool_name, tool_input=tool_input
439
+ )
440
+ )
441
+
442
+ elif payload_type == "toolCallEnd":
443
+ pass
444
+
445
+ elif payload_type == "messageEnd":
446
+ final_message = payload.get("message", "")
447
+ events.append(Completed(timestamp=now_iso(), final_message=final_message))
448
+
449
+ elif payload_type == "error":
450
+ error_message = payload.get("message", "Unknown error")
451
+ events.append(Failed(timestamp=now_iso(), error_message=error_message))
452
+
453
+ elif payload_type == "sessionEnd":
454
+ # Prefer messageEnd content if we already saw it; otherwise treat as failure.
455
+ final_message = payload.get("message") or ""
456
+ if final_message:
457
+ events.append(
458
+ Completed(timestamp=now_iso(), final_message=final_message)
459
+ )
460
+ else:
461
+ events.append(
462
+ Failed(
463
+ timestamp=now_iso(),
464
+ error_message=payload.get("reason", "Session ended early"),
465
+ )
466
+ )
467
+
468
+ return events
469
+
470
+ def _convert_sse_to_agent_event(self, sse: SSEEvent) -> list[AgentEvent]:
471
+ events: list[AgentEvent] = []
472
+
473
+ try:
474
+ payload = json.loads(sse.data) if sse.data else {}
475
+ except json.JSONDecodeError:
476
+ return events
477
+
478
+ payload_type = payload.get("type", "")
479
+ session_id = self._extract_session_id(payload)
480
+
481
+ if payload_type == "textDelta":
482
+ text = payload.get("text", "")
483
+ event = AgentEvent.stream_delta(content=text, delta_type="assistant_stream")
484
+ if session_id:
485
+ event.data["session_id"] = session_id
486
+ events.append(event)
487
+
488
+ elif payload_type == "toolCall":
489
+ tool_name = payload.get("toolName", "")
490
+ tool_input = payload.get("toolInput", {})
491
+ event = AgentEvent.tool_call(tool_name=tool_name, tool_input=tool_input)
492
+ if session_id:
493
+ event.data["session_id"] = session_id
494
+ events.append(event)
495
+
496
+ elif payload_type == "toolCallEnd":
497
+ tool_name = payload.get("toolName", "")
498
+ result = payload.get("result")
499
+ error = payload.get("error")
500
+ event = AgentEvent.tool_result(
501
+ tool_name=tool_name, result=result, error=error
502
+ )
503
+ if session_id:
504
+ event.data["session_id"] = session_id
505
+ events.append(event)
506
+
507
+ elif payload_type == "messageEnd":
508
+ final_message = payload.get("message", "")
509
+ event = AgentEvent.message_complete(final_message=final_message)
510
+ if session_id:
511
+ event.data["session_id"] = session_id
512
+ events.append(event)
513
+
514
+ elif payload_type == "error":
515
+ error_message = payload.get("message", "Unknown error")
516
+ event = AgentEvent.error(error_message=error_message)
517
+ if session_id:
518
+ event.data["session_id"] = session_id
519
+ events.append(event)
520
+
521
+ elif payload_type == "sessionEnd":
522
+ events.append(
523
+ AgentEvent(
524
+ type=AgentEventType.SESSION_ENDED.value,
525
+ timestamp=now_iso(),
526
+ data={
527
+ "reason": payload.get("reason", "unknown"),
528
+ "session_id": session_id,
529
+ },
530
+ )
531
+ )
532
+
533
+ return events
534
+
535
+ def _extract_session_id(self, payload: dict[str, Any]) -> Optional[str]:
536
+ for key in ("session", "sessionId", "sessionID", "session_id"):
537
+ value = payload.get(key)
538
+ if isinstance(value, str):
539
+ return value
540
+ return None
541
+
542
+ def _sse_matches_session(self, sse: SSEEvent) -> bool:
543
+ if not self._session_id:
544
+ return True
545
+ try:
546
+ payload = json.loads(sse.data) if sse.data else {}
547
+ except json.JSONDecodeError:
548
+ return True
549
+ session_id = self._extract_session_id(payload)
550
+ if session_id is None:
551
+ # If server does not tag events, do not drop them.
552
+ return True
553
+ return session_id == self._session_id
554
+
555
+ async def _ensure_client(self) -> OpenCodeClient:
556
+ if self._client is not None:
557
+ return self._client
558
+ if self._supervisor is None or self._workspace_root is None:
559
+ raise RuntimeError("OpenCode client unavailable: supervisor not configured")
560
+ client = await self._supervisor.get_client(self._workspace_root)
561
+ self._client = client
562
+ return client
563
+
564
+ @property
565
+ def last_turn_id(self) -> Optional[str]:
566
+ return self._last_turn_id
567
+
568
+ @property
569
+ def last_token_total(self) -> Optional[dict[str, Any]]:
570
+ return self._last_token_total
571
+
572
+
573
+ def _usage_to_token_total(usage: dict[str, Any]) -> Optional[dict[str, int]]:
574
+ if not isinstance(usage, dict):
575
+ return None
576
+
577
+ def _int(key: str) -> int:
578
+ value = usage.get(key)
579
+ return int(value) if isinstance(value, (int, float)) else 0
580
+
581
+ total = usage.get("totalTokens")
582
+ total_tokens = int(total) if isinstance(total, (int, float)) else None
583
+ input_tokens = _int("inputTokens")
584
+ cached_tokens = _int("cachedInputTokens")
585
+ output_tokens = _int("outputTokens")
586
+ reasoning_tokens = _int("reasoningTokens")
587
+ if total_tokens is None:
588
+ total_tokens = input_tokens + cached_tokens + output_tokens + reasoning_tokens
589
+ return {
590
+ "total": total_tokens,
591
+ "input_tokens": input_tokens,
592
+ "prompt_tokens": input_tokens,
593
+ "cached_input_tokens": cached_tokens,
594
+ "output_tokens": output_tokens,
595
+ "completion_tokens": output_tokens,
596
+ "reasoning_tokens": reasoning_tokens,
597
+ "reasoning_output_tokens": reasoning_tokens,
598
+ }
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import AsyncGenerator, Callable, Optional
5
+
6
+ from ...core.ports.agent_backend import AgentBackend, AgentEvent
7
+ from ...core.ports.run_event import (
8
+ Failed,
9
+ OutputDelta,
10
+ RunEvent,
11
+ Started,
12
+ )
13
+
14
+ _logger = logging.getLogger(__name__)
15
+
16
+ LogHandler = Callable[[str], None]
17
+ EventCallback = Callable[[RunEvent], None]
18
+
19
+
20
+ async def run_turn_with_backend(
21
+ backend: AgentBackend,
22
+ message: str,
23
+ session_id: Optional[str],
24
+ *,
25
+ log_handler: Optional[LogHandler] = None,
26
+ event_callback: Optional[EventCallback] = None,
27
+ ) -> int:
28
+ """
29
+ Execute a turn using the AgentBackend protocol.
30
+
31
+ Returns:
32
+ Exit code (0 for success, non-zero for failure)
33
+ """
34
+ try:
35
+ if not session_id:
36
+ session_id = await backend.start_session(target={}, context={})
37
+
38
+ if event_callback:
39
+ event_callback(Started(timestamp=timestamp(), session_id=session_id))
40
+
41
+ if log_handler:
42
+ log_handler(message)
43
+
44
+ events_consumed = False
45
+ if hasattr(backend, "run_turn_events"):
46
+ async for run_event in backend.run_turn_events(session_id, message):
47
+ events_consumed = True
48
+ if event_callback:
49
+ event_callback(run_event)
50
+ if log_handler and isinstance(run_event, OutputDelta):
51
+ log_handler(run_event.content)
52
+
53
+ if not events_consumed:
54
+ async for agent_event in backend.run_turn(session_id, message):
55
+ if isinstance(agent_event, AgentEvent):
56
+ if log_handler:
57
+ if agent_event.data.get("content"):
58
+ log_handler(agent_event.data["content"])
59
+ elif isinstance(agent_event, str):
60
+ if log_handler:
61
+ log_handler(agent_event)
62
+
63
+ return 0
64
+ except Exception as exc:
65
+ _logger.error("Turn execution failed: %s", exc)
66
+ if event_callback:
67
+ event_callback(Failed(timestamp=timestamp(), error_message=str(exc)))
68
+ return 1
69
+
70
+
71
+ async def stream_turn_events(
72
+ backend: AgentBackend,
73
+ session_id: str,
74
+ ) -> AsyncGenerator[AgentEvent, None]:
75
+ """
76
+ Stream events from a backend for an existing session.
77
+
78
+ This is used for external streaming (e.g., WebSocket UI) where the turn
79
+ has already been initiated and we want to stream events as they arrive.
80
+ """
81
+ if hasattr(backend, "stream_events"):
82
+ async for event in backend.stream_events(session_id):
83
+ yield event
84
+ else:
85
+ yield AgentEvent.stream_delta(content="", delta_type="noop")
86
+
87
+
88
+ def timestamp() -> str:
89
+ from datetime import datetime, timezone
90
+
91
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")