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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import json
4
5
  import logging
5
6
  from pathlib import Path
@@ -8,7 +9,12 @@ from typing import Any, AsyncIterator, Optional
8
9
  from ...core.app_server_events import format_sse
9
10
  from ..base import AgentHarness
10
11
  from ..types import AgentId, ConversationRef, ModelCatalog, ModelSpec, TurnRef
11
- from .runtime import build_turn_id, extract_session_id, extract_turn_id, split_model_id
12
+ from .runtime import (
13
+ build_turn_id,
14
+ extract_session_id,
15
+ extract_turn_id,
16
+ split_model_id,
17
+ )
12
18
  from .supervisor import OpenCodeSupervisor
13
19
 
14
20
  _logger = logging.getLogger(__name__)
@@ -24,8 +30,29 @@ def _coerce_providers(payload: Any) -> list[dict[str, Any]]:
24
30
  return []
25
31
 
26
32
 
33
+ def _iter_provider_models(models_raw: Any) -> list[tuple[str, dict[str, Any]]]:
34
+ models: list[tuple[str, dict[str, Any]]] = []
35
+ if isinstance(models_raw, dict):
36
+ for model_id, model in models_raw.items():
37
+ if isinstance(model_id, str) and model_id:
38
+ if isinstance(model, dict):
39
+ models.append((model_id, model))
40
+ else:
41
+ models.append((model_id, {"id": model_id}))
42
+ return models
43
+ if isinstance(models_raw, list):
44
+ for entry in models_raw:
45
+ if isinstance(entry, dict):
46
+ model_id = entry.get("id") or entry.get("modelID")
47
+ if isinstance(model_id, str) and model_id:
48
+ models.append((model_id, entry))
49
+ elif isinstance(entry, str) and entry:
50
+ models.append((entry, {"id": entry}))
51
+ return models
52
+
53
+
27
54
  class OpenCodeHarness(AgentHarness):
28
- agent_id: AgentId = "opencode"
55
+ agent_id: AgentId = AgentId("opencode")
29
56
  display_name = "OpenCode"
30
57
 
31
58
  def __init__(self, supervisor: OpenCodeSupervisor) -> None:
@@ -58,12 +85,8 @@ class OpenCodeHarness(AgentHarness):
58
85
  provider_id = provider.get("id") or provider.get("providerID")
59
86
  if not isinstance(provider_id, str) or not provider_id:
60
87
  continue
61
- models_map = provider.get("models")
62
- if not isinstance(models_map, dict):
63
- continue
64
- for model_id, model in models_map.items():
65
- if not isinstance(model_id, str) or not isinstance(model, dict):
66
- continue
88
+ models_raw = provider.get("models")
89
+ for model_id, model in _iter_provider_models(models_raw):
67
90
  name = model.get("name") or model.get("id") or model_id
68
91
  display_name = name if isinstance(name, str) and name else model_id
69
92
  capabilities = model.get("capabilities")
@@ -101,7 +124,7 @@ class OpenCodeHarness(AgentHarness):
101
124
  session_id = extract_session_id(result) or result.get("id")
102
125
  if not isinstance(session_id, str) or not session_id:
103
126
  raise ValueError("OpenCode did not return a session id")
104
- return ConversationRef(agent="opencode", id=session_id)
127
+ return ConversationRef(agent=AgentId("opencode"), id=session_id)
105
128
 
106
129
  async def list_conversations(self, workspace_root: Path) -> list[ConversationRef]:
107
130
  client = await self._supervisor.get_client(workspace_root)
@@ -117,7 +140,9 @@ class OpenCodeHarness(AgentHarness):
117
140
  for entry in sessions:
118
141
  session_id = extract_session_id(entry) or entry.get("id")
119
142
  if isinstance(session_id, str) and session_id:
120
- conversations.append(ConversationRef(agent="opencode", id=session_id))
143
+ conversations.append(
144
+ ConversationRef(agent=AgentId("opencode"), id=session_id)
145
+ )
121
146
  return conversations
122
147
 
123
148
  async def resume_conversation(
@@ -129,7 +154,7 @@ class OpenCodeHarness(AgentHarness):
129
154
  except Exception:
130
155
  result = {}
131
156
  session_id = extract_session_id(result) or conversation_id
132
- return ConversationRef(agent="opencode", id=session_id)
157
+ return ConversationRef(agent=AgentId("opencode"), id=session_id)
133
158
 
134
159
  async def start_turn(
135
160
  self,
@@ -144,7 +169,7 @@ class OpenCodeHarness(AgentHarness):
144
169
  ) -> TurnRef:
145
170
  client = await self._supervisor.get_client(workspace_root)
146
171
  model_payload = split_model_id(model)
147
- await client.prompt(
172
+ await client.prompt_async(
148
173
  conversation_id,
149
174
  message=prompt,
150
175
  model=model_payload,
@@ -168,13 +193,23 @@ class OpenCodeHarness(AgentHarness):
168
193
  ) -> TurnRef:
169
194
  client = await self._supervisor.get_client(workspace_root)
170
195
  arguments = prompt if prompt else ""
171
- result = await client.send_command(
172
- conversation_id,
173
- command="review",
174
- arguments=arguments,
175
- model=model,
176
- )
177
- turn_id = extract_turn_id(conversation_id, result)
196
+
197
+ async def _send_review() -> None:
198
+ try:
199
+ result = await client.send_command(
200
+ conversation_id,
201
+ command="review",
202
+ arguments=arguments,
203
+ model=model,
204
+ )
205
+ turn_id = extract_turn_id(conversation_id, result)
206
+ if turn_id:
207
+ _logger.debug("OpenCode review started: %s", turn_id)
208
+ except Exception as exc:
209
+ _logger.warning("OpenCode review command failed: %s", exc)
210
+
211
+ asyncio.create_task(_send_review())
212
+ turn_id = build_turn_id(conversation_id)
178
213
  return TurnRef(conversation_id=conversation_id, turn_id=turn_id)
179
214
 
180
215
  async def interrupt(
@@ -199,7 +234,23 @@ class OpenCodeHarness(AgentHarness):
199
234
  except json.JSONDecodeError:
200
235
  parsed = {"raw": payload}
201
236
  session_id = extract_session_id(parsed)
202
- if event.event == "session.idle" and session_id == conversation_id:
237
+ status_type = None
238
+ if event.event == "session.status" and isinstance(parsed, dict):
239
+ properties = parsed.get("properties")
240
+ if isinstance(properties, dict):
241
+ status = properties.get("status") or {}
242
+ else:
243
+ status = parsed.get("status") or {}
244
+ if isinstance(status, dict):
245
+ status_type = status.get("type") or status.get("status")
246
+ if (
247
+ event.event == "session.idle"
248
+ or (
249
+ event.event == "session.status"
250
+ and isinstance(status_type, str)
251
+ and status_type.lower() == "idle"
252
+ )
253
+ ) and session_id == conversation_id:
203
254
  break
204
255
  if session_id and session_id != conversation_id:
205
256
  continue
@@ -0,0 +1,225 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Optional
5
+
6
+ from ...core.text_delta_coalescer import TextDeltaCoalescer
7
+
8
+
9
+ class OpenCodeEventFormatter:
10
+ def __init__(self) -> None:
11
+ self._seen_reasoning_parts: set[str] = set()
12
+ self._reasoning_coalescers: dict[str, TextDeltaCoalescer] = {}
13
+ self._tool_last_status: dict[str, str] = {}
14
+ self._seen_patch_hashes: set[str] = set()
15
+ self._logger = logging.getLogger(__name__)
16
+
17
+ def flush_all_reasoning(self) -> list[str]:
18
+ lines: list[str] = []
19
+ for coalescer in self._reasoning_coalescers.values():
20
+ remaining = coalescer.flush_all()
21
+ for line in remaining:
22
+ if line.strip():
23
+ lines.append(f"**{line.strip()}**")
24
+ self._reasoning_coalescers.clear()
25
+ return lines
26
+
27
+ def reset(self) -> None:
28
+ self._seen_reasoning_parts.clear()
29
+ self._reasoning_coalescers.clear()
30
+ self._tool_last_status.clear()
31
+ self._seen_patch_hashes.clear()
32
+
33
+ def format_part(
34
+ self, part_type: str, part: dict[str, Any], delta_text: Optional[str]
35
+ ) -> list[str]:
36
+ lines: list[str] = []
37
+ part_id = part.get("id") or part.get("partId")
38
+
39
+ if part_type == "reasoning":
40
+ lines.extend(self._format_reasoning_part(part_id, delta_text))
41
+
42
+ elif part_type == "tool":
43
+ lines.extend(self._format_tool_part(part))
44
+
45
+ elif part_type == "patch":
46
+ lines.extend(self._format_patch_part(part))
47
+
48
+ return lines
49
+
50
+ def _format_reasoning_part(
51
+ self, part_id: Optional[str], delta_text: Optional[str]
52
+ ) -> list[str]:
53
+ lines: list[str] = []
54
+ key = part_id or "reasoning"
55
+
56
+ if delta_text:
57
+ if key not in self._seen_reasoning_parts:
58
+ lines.append("thinking")
59
+ self._seen_reasoning_parts.add(key)
60
+
61
+ if key not in self._reasoning_coalescers:
62
+ self._reasoning_coalescers[key] = TextDeltaCoalescer()
63
+ coalescer = self._reasoning_coalescers[key]
64
+ coalescer.add(delta_text)
65
+ complete_lines = coalescer.flush_lines()
66
+ for line in complete_lines:
67
+ if line.strip():
68
+ lines.append(f"**{line.strip()}**")
69
+ return lines
70
+
71
+ def _format_tool_part(self, part: dict[str, Any]) -> list[str]:
72
+ lines: list[str] = []
73
+ tool_id = part.get("callID") or part.get("id")
74
+ tool_name = part.get("tool") or part.get("name") or ""
75
+
76
+ if not isinstance(tool_name, str) or not tool_name:
77
+ return lines
78
+
79
+ state = part.get("state", {})
80
+ if not isinstance(state, dict):
81
+ state = {}
82
+
83
+ status = state.get("status")
84
+ if isinstance(status, str) and status:
85
+ key = f"{tool_id}:{tool_name}" if tool_id else tool_name
86
+ last_status = self._tool_last_status.get(key)
87
+
88
+ if last_status != status:
89
+ self._tool_last_status[key] = status
90
+
91
+ if status in ("running", "pending"):
92
+ lines.append("exec")
93
+ lines.append(f"tool: {tool_name}")
94
+
95
+ elif status == "completed":
96
+ if last_status not in ("running", "pending"):
97
+ lines.append("exec")
98
+ lines.append(f"tool: {tool_name}")
99
+ exit_code = state.get("exitCode")
100
+ if exit_code is not None:
101
+ lines.append(f"exit {exit_code}")
102
+
103
+ elif status in ("error", "failed"):
104
+ if last_status not in ("running", "pending"):
105
+ lines.append("exec")
106
+ lines.append(f"tool: {tool_name}")
107
+ error = state.get("error")
108
+ if isinstance(error, (str, dict)):
109
+ if isinstance(error, dict):
110
+ error = error.get("message") or error.get("error")
111
+ if isinstance(error, str) and error:
112
+ lines.append(f"error: {error}")
113
+
114
+ elif tool_id is None:
115
+ lines.append("exec")
116
+ lines.append(f"tool: {tool_name}")
117
+
118
+ input_preview: Optional[str] = None
119
+ for key in ("input", "command", "cmd", "script"):
120
+ value = part.get(key)
121
+ if isinstance(value, str) and value.strip():
122
+ input_preview = value.strip()
123
+ break
124
+ if input_preview is None:
125
+ args = part.get("args") or part.get("arguments") or part.get("params")
126
+ if isinstance(args, dict):
127
+ for key in ("command", "cmd", "script", "input"):
128
+ value = args.get(key)
129
+ if isinstance(value, str) and value.strip():
130
+ input_preview = value.strip()
131
+ break
132
+ elif isinstance(args, str) and args.strip():
133
+ input_preview = args.strip()
134
+ if input_preview:
135
+ if len(input_preview) > 240:
136
+ input_preview = input_preview[:240] + "…"
137
+ lines.append(f"input: {input_preview}")
138
+
139
+ return lines
140
+
141
+ def _format_patch_part(self, part: dict[str, Any]) -> list[str]:
142
+ lines: list[str] = []
143
+ patch_hash = part.get("hash")
144
+
145
+ if isinstance(patch_hash, str) and patch_hash:
146
+ if patch_hash in self._seen_patch_hashes:
147
+ return lines
148
+ self._seen_patch_hashes.add(patch_hash)
149
+
150
+ files = part.get("files")
151
+ if isinstance(files, list):
152
+ if files:
153
+ lines.append("file update")
154
+ for file_entry in files:
155
+ if isinstance(file_entry, dict):
156
+ path = file_entry.get("path") or file_entry.get("file")
157
+ action = file_entry.get("status") or "M"
158
+ if isinstance(path, str) and path:
159
+ lines.append(f"{action} {path}")
160
+ elif isinstance(file_entry, str):
161
+ lines.append(f"M {file_entry}")
162
+
163
+ elif isinstance(files, str):
164
+ lines.append("file update")
165
+ lines.append(f"M {files}")
166
+
167
+ return lines
168
+
169
+ def format_usage(self, usage: dict[str, Any]) -> list[str]:
170
+ lines: list[str] = []
171
+
172
+ total = usage.get("totalTokens")
173
+ if isinstance(total, int):
174
+ input_tokens = usage.get("inputTokens")
175
+ cached_tokens = usage.get("cachedInputTokens")
176
+ output_tokens = usage.get("outputTokens")
177
+ reasoning_tokens = usage.get("reasoningTokens")
178
+
179
+ parts: list[str] = []
180
+ if isinstance(input_tokens, int):
181
+ parts.append(f"input: {input_tokens}")
182
+ if isinstance(cached_tokens, int) and cached_tokens > 0:
183
+ parts.append(f"cached: {cached_tokens}")
184
+ if isinstance(output_tokens, int):
185
+ parts.append(f"output: {output_tokens}")
186
+ if isinstance(reasoning_tokens, int):
187
+ parts.append(f"reasoning: {reasoning_tokens}")
188
+
189
+ if parts:
190
+ lines.append(f"tokens used - {', '.join(parts)}")
191
+ else:
192
+ lines.append(f"tokens used: {total}")
193
+
194
+ context_window = usage.get("modelContextWindow")
195
+ if isinstance(context_window, int) and context_window > 0:
196
+ lines.append(f"context window: {context_window}")
197
+
198
+ return lines
199
+
200
+ def format_permission(self, properties: dict[str, Any]) -> list[str]:
201
+ lines: list[str] = []
202
+
203
+ reason = properties.get("reason") or properties.get("message")
204
+ if isinstance(reason, str) and reason:
205
+ lines.append(f"permission: {reason}")
206
+ else:
207
+ lines.append("permission requested")
208
+
209
+ return lines
210
+
211
+ def format_error(self, error: Any) -> list[str]:
212
+ lines: list[str] = []
213
+
214
+ message = None
215
+ if isinstance(error, dict):
216
+ message = error.get("message") or error.get("error") or error.get("detail")
217
+ elif isinstance(error, str):
218
+ message = error
219
+
220
+ if isinstance(message, str) and message:
221
+ lines.append(f"error: {message}")
222
+ else:
223
+ lines.append("error: session error")
224
+
225
+ return lines
@@ -0,0 +1,261 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Awaitable, Callable, Optional
9
+
10
+ from .runtime import (
11
+ PERMISSION_ALLOW,
12
+ OpenCodeTurnOutput,
13
+ build_turn_id,
14
+ collect_opencode_output,
15
+ extract_session_id,
16
+ opencode_missing_env,
17
+ parse_message_response,
18
+ split_model_id,
19
+ )
20
+ from .supervisor import OpenCodeSupervisor
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class OpenCodeRunResult:
25
+ session_id: str
26
+ turn_id: str
27
+ output_text: str
28
+ output_error: Optional[str]
29
+ stopped: bool
30
+ timed_out: bool
31
+
32
+
33
+ @dataclass
34
+ class OpenCodeRunConfig:
35
+ agent: str
36
+ model: Optional[str]
37
+ reasoning: Optional[str]
38
+ prompt: str
39
+ workspace_root: str
40
+ timeout_seconds: int = 3600
41
+ interrupt_grace_seconds: int = 10
42
+ on_turn_start: Optional[Callable[[str, str], Awaitable[None]]] = None
43
+ permission_policy: str = PERMISSION_ALLOW
44
+
45
+
46
+ async def run_opencode_prompt(
47
+ supervisor: OpenCodeSupervisor,
48
+ config: OpenCodeRunConfig,
49
+ *,
50
+ should_stop: Optional[Callable[[], bool]] = None,
51
+ logger: Optional[logging.Logger] = None,
52
+ ) -> OpenCodeRunResult:
53
+ client = await supervisor.get_client(Path(config.workspace_root))
54
+
55
+ session_id: Optional[str] = None
56
+ try:
57
+ session = await client.create_session(directory=config.workspace_root)
58
+ session_id = extract_session_id(session, allow_fallback_id=True)
59
+ if not isinstance(session_id, str) or not session_id:
60
+ raise ValueError("OpenCode did not return a session id")
61
+ except Exception as exc:
62
+ raise RuntimeError(f"Failed to create OpenCode session: {exc}") from exc
63
+
64
+ model_payload = split_model_id(config.model)
65
+ missing_env = await opencode_missing_env(
66
+ client, config.workspace_root, model_payload
67
+ )
68
+ if missing_env:
69
+ provider_id = model_payload.get("providerID") if model_payload else None
70
+ missing_label = ", ".join(missing_env)
71
+ raise RuntimeError(
72
+ f"OpenCode provider {provider_id or 'selected'} requires env vars: {missing_label}"
73
+ )
74
+
75
+ opencode_turn_started = False
76
+ await supervisor.mark_turn_started(Path(config.workspace_root))
77
+ opencode_turn_started = True
78
+ turn_id = build_turn_id(session_id)
79
+
80
+ if config.on_turn_start is not None:
81
+ try:
82
+ await config.on_turn_start(session_id, turn_id)
83
+ except Exception:
84
+ pass
85
+
86
+ stopped = False
87
+ timed_out = False
88
+ output_result: Optional[OpenCodeTurnOutput] = None
89
+
90
+ stop_task = None
91
+ if should_stop is not None:
92
+
93
+ async def _wait_for_stop() -> bool:
94
+ while True:
95
+ if should_stop():
96
+ return True
97
+ await asyncio.sleep(0.2)
98
+
99
+ stop_task = asyncio.create_task(_wait_for_stop())
100
+
101
+ async def _abort_session(reason: str) -> None:
102
+ try:
103
+ await client.abort(session_id)
104
+ except Exception as exc:
105
+ if logger is not None:
106
+ logger.warning(f"OpenCode abort failed ({reason}): {exc}")
107
+
108
+ permission_policy = config.permission_policy or PERMISSION_ALLOW
109
+ ready_event = asyncio.Event()
110
+ output_task = asyncio.create_task(
111
+ collect_opencode_output(
112
+ client,
113
+ session_id=session_id,
114
+ workspace_path=config.workspace_root,
115
+ model_payload=model_payload,
116
+ permission_policy=permission_policy,
117
+ should_stop=should_stop,
118
+ ready_event=ready_event,
119
+ )
120
+ )
121
+ with contextlib.suppress(asyncio.TimeoutError):
122
+ await asyncio.wait_for(ready_event.wait(), timeout=2.0)
123
+ prompt_task = asyncio.create_task(
124
+ client.prompt_async(
125
+ session_id,
126
+ message=config.prompt,
127
+ model=model_payload,
128
+ variant=config.reasoning,
129
+ )
130
+ )
131
+ timeout_task = asyncio.create_task(asyncio.sleep(config.timeout_seconds))
132
+
133
+ try:
134
+
135
+ async def _finish_output(
136
+ ignore_errors: bool,
137
+ ) -> Optional[OpenCodeTurnOutput]:
138
+ if output_task.done():
139
+ try:
140
+ return await output_task
141
+ except Exception as exc:
142
+ if not ignore_errors:
143
+ raise
144
+ if logger is not None:
145
+ logger.warning(f"OpenCode output failed after interrupt: {exc}")
146
+ return None
147
+
148
+ grace_seconds = max(0, config.interrupt_grace_seconds or 0)
149
+ if grace_seconds <= 0:
150
+ output_task.cancel()
151
+ with contextlib.suppress(asyncio.CancelledError):
152
+ await output_task
153
+ return None
154
+
155
+ try:
156
+ return await asyncio.wait_for(output_task, timeout=grace_seconds)
157
+ except asyncio.TimeoutError:
158
+ output_task.cancel()
159
+ with contextlib.suppress(asyncio.CancelledError):
160
+ await output_task
161
+ if logger is not None:
162
+ logger.warning("OpenCode output did not stop within grace period")
163
+ return None
164
+ except Exception as exc:
165
+ if not ignore_errors:
166
+ raise
167
+ if logger is not None:
168
+ logger.warning(f"OpenCode output failed after interrupt: {exc}")
169
+ return None
170
+
171
+ tasks = {output_task, prompt_task, timeout_task}
172
+ if stop_task is not None:
173
+ tasks.add(stop_task)
174
+
175
+ while True:
176
+ done, pending = await asyncio.wait(
177
+ tasks, return_when=asyncio.FIRST_COMPLETED
178
+ )
179
+
180
+ if output_task in done:
181
+ output_result = await output_task
182
+ if should_stop is not None and should_stop():
183
+ stopped = True
184
+ break
185
+
186
+ if stop_task is not None and stop_task in done:
187
+ stopped = True
188
+ if logger is not None:
189
+ logger.info("OpenCode prompt stopped")
190
+ await _abort_session("stop")
191
+ output_result = await _finish_output(ignore_errors=True)
192
+ break
193
+
194
+ if timeout_task in done:
195
+ timed_out = True
196
+ if logger is not None:
197
+ logger.warning("OpenCode prompt timed out")
198
+ await _abort_session("timeout")
199
+ output_result = await _finish_output(ignore_errors=True)
200
+ break
201
+
202
+ if prompt_task in done:
203
+ try:
204
+ await prompt_task
205
+ except Exception as exc:
206
+ if logger is not None:
207
+ logger.error(f"OpenCode prompt failed: {exc}")
208
+ output_task.cancel()
209
+ with contextlib.suppress(asyncio.CancelledError):
210
+ await output_task
211
+ raise RuntimeError(f"OpenCode prompt failed: {exc}") from exc
212
+ tasks.discard(prompt_task)
213
+ tasks = pending
214
+
215
+ finally:
216
+ timeout_task.cancel()
217
+ with contextlib.suppress(asyncio.CancelledError):
218
+ await timeout_task
219
+ if stop_task is not None:
220
+ stop_task.cancel()
221
+ with contextlib.suppress(asyncio.CancelledError):
222
+ await stop_task
223
+ if not prompt_task.done():
224
+ prompt_task.cancel()
225
+ with contextlib.suppress(asyncio.CancelledError):
226
+ await prompt_task
227
+ if opencode_turn_started:
228
+ try:
229
+ await supervisor.mark_turn_finished(Path(config.workspace_root))
230
+ except Exception:
231
+ pass
232
+
233
+ output_text = output_result.text if output_result else ""
234
+ output_error = output_result.error if output_result else None
235
+ if prompt_task.done() and not output_text:
236
+ try:
237
+ prompt_response = prompt_task.result()
238
+ except Exception:
239
+ prompt_response = None
240
+ if prompt_response is not None:
241
+ fallback = parse_message_response(prompt_response)
242
+ if fallback.text:
243
+ output_text = fallback.text
244
+ if fallback.error and not output_error:
245
+ output_error = fallback.error
246
+
247
+ return OpenCodeRunResult(
248
+ session_id=session_id,
249
+ turn_id=turn_id,
250
+ output_text=output_text,
251
+ output_error=output_error,
252
+ stopped=stopped,
253
+ timed_out=timed_out,
254
+ )
255
+
256
+
257
+ __all__ = [
258
+ "OpenCodeRunResult",
259
+ "OpenCodeRunConfig",
260
+ "run_opencode_prompt",
261
+ ]