codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -1,18 +1,41 @@
1
+ import asyncio
2
+ import contextlib
1
3
  import json
2
4
  import logging
5
+ from pathlib import Path
3
6
  from typing import Any, AsyncGenerator, Dict, Optional
4
7
 
5
8
  from ...agents.opencode.client import OpenCodeClient
6
9
  from ...agents.opencode.events import SSEEvent
7
- from .agent_backend import AgentBackend, AgentEvent, AgentEventType, now_iso
8
- from .run_event import (
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 (
9
29
  Completed,
10
30
  Failed,
11
31
  OutputDelta,
12
32
  RunEvent,
33
+ RunNotice,
13
34
  Started,
35
+ TokenUsage,
14
36
  ToolCall,
15
37
  )
38
+ from ...core.text_delta_coalescer import StreamingTextCoalescer
16
39
 
17
40
  _logger = logging.getLogger(__name__)
18
41
 
@@ -20,32 +43,74 @@ _logger = logging.getLogger(__name__)
20
43
  class OpenCodeBackend(AgentBackend):
21
44
  def __init__(
22
45
  self,
23
- base_url: str,
24
46
  *,
47
+ base_url: Optional[str] = None,
48
+ supervisor: Optional[OpenCodeSupervisor] = None,
49
+ workspace_root: Optional[Path] = None,
25
50
  auth: Optional[tuple[str, str]] = None,
26
51
  timeout: Optional[float] = None,
27
52
  agent: Optional[str] = None,
28
- model: Optional[dict[str, 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,
29
58
  ):
30
- self._client = OpenCodeClient(
31
- base_url=base_url,
32
- auth=auth,
33
- timeout=timeout,
34
- )
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
35
71
  self._agent = agent
36
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
37
77
 
38
78
  self._session_id: Optional[str] = None
39
79
  self._message_count: int = 0
40
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
41
95
 
42
96
  async def start_session(self, target: dict, context: dict) -> str:
43
- result = await self._client.create_session(
44
- title=f"Flow session {self._message_count}",
45
- directory=None,
46
- )
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)
47
113
 
48
- self._session_id = result.get("id")
49
114
  if not self._session_id:
50
115
  raise RuntimeError("Failed to create OpenCode session: missing session ID")
51
116
 
@@ -56,6 +121,7 @@ class OpenCodeBackend(AgentBackend):
56
121
  async def run_turn(
57
122
  self, session_id: str, message: str
58
123
  ) -> AsyncGenerator[AgentEvent, None]:
124
+ client = await self._ensure_client()
59
125
  if session_id:
60
126
  self._session_id = session_id
61
127
  if not self._session_id:
@@ -65,11 +131,11 @@ class OpenCodeBackend(AgentBackend):
65
131
 
66
132
  yield AgentEvent.stream_delta(content=message, delta_type="user_message")
67
133
 
68
- await self._client.send_message(
134
+ await client.send_message(
69
135
  self._session_id,
70
136
  message=message,
71
137
  agent=self._agent,
72
- model=self._model,
138
+ model=split_model_id(self._model) if self._model else None,
73
139
  )
74
140
 
75
141
  self._message_count += 1
@@ -79,34 +145,192 @@ class OpenCodeBackend(AgentBackend):
79
145
  async def run_turn_events(
80
146
  self, session_id: str, message: str
81
147
  ) -> AsyncGenerator[RunEvent, None]:
148
+ client = await self._ensure_client()
149
+ workspace_root = self._workspace_root or Path(".")
150
+
82
151
  if session_id:
83
152
  self._session_id = session_id
84
153
  if not self._session_id:
85
- self._session_id = await self.start_session(target={}, context={})
154
+ self._session_id = await self.start_session(
155
+ target={},
156
+ context={"workspace": str(workspace_root)},
157
+ )
86
158
 
87
159
  _logger.info("Running turn events on session %s", self._session_id)
88
160
 
161
+ self._last_turn_id = build_turn_id(self._session_id)
162
+
89
163
  yield Started(timestamp=now_iso(), session_id=self._session_id)
90
164
 
91
165
  yield OutputDelta(
92
166
  timestamp=now_iso(), content=message, delta_type="user_message"
93
167
  )
94
168
 
95
- await self._client.send_message(
96
- self._session_id,
97
- message=message,
98
- agent=self._agent,
99
- model=self._model,
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
100
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
101
184
 
102
- self._message_count += 1
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
+ )
103
257
 
258
+ output_result = None
104
259
  try:
105
- async for run_event in self._yield_run_events_until_completion():
106
- yield run_event
107
- except Exception as e:
108
- _logger.error("Error during turn execution: %s", e)
109
- yield Failed(timestamp=now_iso(), error_message=str(e))
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="")
110
334
 
111
335
  async def stream_events(self, session_id: str) -> AsyncGenerator[AgentEvent, None]:
112
336
  if session_id:
@@ -114,15 +338,17 @@ class OpenCodeBackend(AgentBackend):
114
338
  if not self._session_id:
115
339
  raise RuntimeError("Session not started. Call start_session() first.")
116
340
 
117
- async for sse in self._client.stream_events(directory=None):
341
+ client = await self._ensure_client()
342
+ async for sse in client.stream_events(directory=None):
118
343
  for agent_event in self._convert_sse_to_agent_event(sse):
119
344
  yield agent_event
120
345
 
121
346
  async def interrupt(self, session_id: str) -> None:
122
347
  target_session = session_id or self._session_id
123
348
  if target_session:
349
+ client = await self._ensure_client()
124
350
  try:
125
- await self._client.abort(target_session)
351
+ await client.abort(target_session)
126
352
  _logger.info("Interrupted OpenCode session %s", target_session)
127
353
  except Exception as e:
128
354
  _logger.warning("Failed to interrupt session: %s", e)
@@ -140,7 +366,8 @@ class OpenCodeBackend(AgentBackend):
140
366
  if self._session_id:
141
367
  paths.insert(0, f"/session/{self._session_id}/event")
142
368
  try:
143
- async for sse in self._client.stream_events(
369
+ client = await self._ensure_client()
370
+ async for sse in client.stream_events(
144
371
  directory=None,
145
372
  paths=paths,
146
373
  ):
@@ -168,7 +395,8 @@ class OpenCodeBackend(AgentBackend):
168
395
  if self._session_id:
169
396
  paths.insert(0, f"/session/{self._session_id}/event")
170
397
  try:
171
- async for sse in self._client.stream_events(
398
+ client = await self._ensure_client()
399
+ async for sse in client.stream_events(
172
400
  directory=None,
173
401
  paths=paths,
174
402
  ):
@@ -323,3 +551,48 @@ class OpenCodeBackend(AgentBackend):
323
551
  # If server does not tag events, do not drop them.
324
552
  return True
325
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,86 @@
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
+ now_iso,
13
+ )
14
+
15
+ _logger = logging.getLogger(__name__)
16
+
17
+ LogHandler = Callable[[str], None]
18
+ EventCallback = Callable[[RunEvent], None]
19
+
20
+
21
+ async def run_turn_with_backend(
22
+ backend: AgentBackend,
23
+ message: str,
24
+ session_id: Optional[str],
25
+ *,
26
+ log_handler: Optional[LogHandler] = None,
27
+ event_callback: Optional[EventCallback] = None,
28
+ ) -> int:
29
+ """
30
+ Execute a turn using the AgentBackend protocol.
31
+
32
+ Returns:
33
+ Exit code (0 for success, non-zero for failure)
34
+ """
35
+ try:
36
+ if not session_id:
37
+ session_id = await backend.start_session(target={}, context={})
38
+
39
+ if event_callback:
40
+ event_callback(Started(timestamp=now_iso(), session_id=session_id))
41
+
42
+ if log_handler:
43
+ log_handler(message)
44
+
45
+ events_consumed = False
46
+ if hasattr(backend, "run_turn_events"):
47
+ async for run_event in backend.run_turn_events(session_id, message):
48
+ events_consumed = True
49
+ if event_callback:
50
+ event_callback(run_event)
51
+ if log_handler and isinstance(run_event, OutputDelta):
52
+ log_handler(run_event.content)
53
+
54
+ if not events_consumed:
55
+ async for agent_event in backend.run_turn(session_id, message):
56
+ if isinstance(agent_event, AgentEvent):
57
+ if log_handler:
58
+ if agent_event.data.get("content"):
59
+ log_handler(agent_event.data["content"])
60
+ elif isinstance(agent_event, str):
61
+ if log_handler:
62
+ log_handler(agent_event)
63
+
64
+ return 0
65
+ except Exception as exc:
66
+ _logger.error("Turn execution failed: %s", exc)
67
+ if event_callback:
68
+ event_callback(Failed(timestamp=now_iso(), error_message=str(exc)))
69
+ return 1
70
+
71
+
72
+ async def stream_turn_events(
73
+ backend: AgentBackend,
74
+ session_id: str,
75
+ ) -> AsyncGenerator[AgentEvent, None]:
76
+ """
77
+ Stream events from a backend for an existing session.
78
+
79
+ This is used for external streaming (e.g., WebSocket UI) where the turn
80
+ has already been initiated and we want to stream events as they arrive.
81
+ """
82
+ if hasattr(backend, "stream_events"):
83
+ async for event in backend.stream_events(session_id):
84
+ yield event
85
+ else:
86
+ yield AgentEvent.stream_delta(content="", delta_type="noop")