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,22 +1,90 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import json
5
+ import logging
4
6
  import os
5
7
  import time
8
+ from contextlib import suppress
6
9
  from dataclasses import dataclass
7
10
  from pathlib import Path
8
- from typing import Any, AsyncIterator, Awaitable, Callable, Optional
11
+ from typing import (
12
+ Any,
13
+ AsyncIterator,
14
+ Awaitable,
15
+ Callable,
16
+ MutableMapping,
17
+ Optional,
18
+ cast,
19
+ )
9
20
 
21
+ import httpx
22
+
23
+ from ...core.logging_utils import log_event
24
+ from ...core.utils import infer_home_from_workspace
10
25
  from .events import SSEEvent
11
26
 
12
27
  PermissionDecision = str
13
28
  PermissionHandler = Callable[[str, dict[str, Any]], Awaitable[PermissionDecision]]
29
+ QuestionHandler = Callable[[str, dict[str, Any]], Awaitable[Optional[list[list[str]]]]]
14
30
  PartHandler = Callable[[str, dict[str, Any], Optional[str]], Awaitable[None]]
15
31
 
16
32
  PERMISSION_ALLOW = "allow"
17
33
  PERMISSION_DENY = "deny"
18
34
  PERMISSION_ASK = "ask"
19
35
 
36
+ OPENCODE_PERMISSION_ONCE = "once"
37
+ OPENCODE_PERMISSION_ALWAYS = "always"
38
+ OPENCODE_PERMISSION_REJECT = "reject"
39
+
40
+ _OPENCODE_STREAM_STALL_TIMEOUT_SECONDS = 60.0
41
+ _OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS = (0.5, 1.0, 2.0, 5.0, 10.0)
42
+ _OPENCODE_IDLE_STATUS_VALUES = {
43
+ "idle",
44
+ "done",
45
+ "completed",
46
+ "complete",
47
+ "finished",
48
+ "success",
49
+ }
50
+ _OPENCODE_USAGE_TOTAL_KEYS = ("totalTokens", "total_tokens", "total")
51
+ _OPENCODE_USAGE_INPUT_KEYS = (
52
+ "inputTokens",
53
+ "input_tokens",
54
+ "promptTokens",
55
+ "prompt_tokens",
56
+ )
57
+ _OPENCODE_USAGE_CACHED_KEYS = (
58
+ "cachedTokens",
59
+ "cached_tokens",
60
+ "cachedInputTokens",
61
+ "cached_input_tokens",
62
+ )
63
+ _OPENCODE_USAGE_OUTPUT_KEYS = (
64
+ "outputTokens",
65
+ "output_tokens",
66
+ "completionTokens",
67
+ "completion_tokens",
68
+ )
69
+ _OPENCODE_USAGE_REASONING_KEYS = (
70
+ "reasoningTokens",
71
+ "reasoning_tokens",
72
+ "reasoningOutputTokens",
73
+ "reasoning_output_tokens",
74
+ )
75
+ _OPENCODE_CONTEXT_WINDOW_KEYS = (
76
+ "modelContextWindow",
77
+ "contextWindow",
78
+ "context_window",
79
+ "contextWindowSize",
80
+ "context_window_size",
81
+ "contextLength",
82
+ "context_length",
83
+ "maxTokens",
84
+ "max_tokens",
85
+ )
86
+ _OPENCODE_MODEL_CONTEXT_KEYS = ("context",) + _OPENCODE_CONTEXT_WINDOW_KEYS
87
+
20
88
 
21
89
  @dataclass(frozen=True)
22
90
  class OpenCodeMessageResult:
@@ -60,17 +128,19 @@ def extract_session_id(
60
128
  return value
61
129
  properties = payload.get("properties")
62
130
  if isinstance(properties, dict):
63
- value = properties.get("sessionID")
64
- if isinstance(value, str) and value:
65
- return value
66
- part = properties.get("part")
67
- if isinstance(part, dict):
68
- value = part.get("sessionID")
131
+ for key in ("sessionID", "sessionId", "session_id"):
132
+ value = properties.get(key)
69
133
  if isinstance(value, str) and value:
70
134
  return value
135
+ part = properties.get("part")
136
+ if isinstance(part, dict):
137
+ for key in ("sessionID", "sessionId", "session_id"):
138
+ value = part.get(key)
139
+ if isinstance(value, str) and value:
140
+ return value
71
141
  session = payload.get("session")
72
142
  if isinstance(session, dict):
73
- return extract_session_id(session, allow_fallback_id=allow_fallback_id)
143
+ return extract_session_id(session, allow_fallback_id=True)
74
144
  return None
75
145
 
76
146
 
@@ -89,6 +159,32 @@ def extract_turn_id(session_id: str, payload: Any) -> str:
89
159
  return build_turn_id(session_id)
90
160
 
91
161
 
162
+ def _extract_model_ids(payload: Any) -> tuple[Optional[str], Optional[str]]:
163
+ if not isinstance(payload, dict):
164
+ return None, None
165
+ for container in (payload, payload.get("properties"), payload.get("info")):
166
+ if not isinstance(container, dict):
167
+ continue
168
+ provider_id = (
169
+ container.get("providerID")
170
+ or container.get("providerId")
171
+ or container.get("provider_id")
172
+ )
173
+ model_id = (
174
+ container.get("modelID")
175
+ or container.get("modelId")
176
+ or container.get("model_id")
177
+ )
178
+ if (
179
+ isinstance(provider_id, str)
180
+ and provider_id.strip()
181
+ and isinstance(model_id, str)
182
+ and model_id.strip()
183
+ ):
184
+ return provider_id, model_id
185
+ return None, None
186
+
187
+
92
188
  def parse_message_response(payload: Any) -> OpenCodeMessageResult:
93
189
  if not isinstance(payload, dict):
94
190
  return OpenCodeMessageResult(text="")
@@ -140,6 +236,139 @@ def _extract_permission_request(payload: Any) -> tuple[Optional[str], dict[str,
140
236
  return None, {}
141
237
 
142
238
 
239
+ def _normalize_question_policy(policy: Optional[str]) -> str:
240
+ if not policy:
241
+ return "ignore"
242
+ normalized = policy.strip().lower()
243
+ if normalized in ("auto_first_option", "auto_first", "first", "first_option"):
244
+ return "auto_first_option"
245
+ if normalized in ("auto_unanswered", "unanswered", "empty"):
246
+ return "auto_unanswered"
247
+ if normalized in ("reject", "deny", "cancel"):
248
+ return "reject"
249
+ if normalized in ("ignore", "none"):
250
+ return "ignore"
251
+ return "ignore"
252
+
253
+
254
+ def _normalize_questions(raw: Any) -> list[dict[str, Any]]:
255
+ if not isinstance(raw, list):
256
+ return []
257
+ questions: list[dict[str, Any]] = []
258
+ for item in raw:
259
+ if isinstance(item, dict):
260
+ questions.append(item)
261
+ elif isinstance(item, str):
262
+ questions.append({"text": item})
263
+ return questions
264
+
265
+
266
+ def _extract_question_request(payload: Any) -> tuple[Optional[str], dict[str, Any]]:
267
+ if not isinstance(payload, dict):
268
+ return None, {}
269
+ properties = payload.get("properties")
270
+ base = properties if isinstance(properties, dict) else payload
271
+ if not isinstance(base, dict):
272
+ base = payload
273
+ request_id = None
274
+ for container in (base, payload):
275
+ if not isinstance(container, dict):
276
+ continue
277
+ for key in ("id", "requestID", "requestId"):
278
+ value = container.get(key)
279
+ if isinstance(value, str) and value:
280
+ request_id = value
281
+ break
282
+ if request_id:
283
+ break
284
+ questions = None
285
+ for container in (base, payload):
286
+ if not isinstance(container, dict):
287
+ continue
288
+ candidate = container.get("questions")
289
+ if isinstance(candidate, list):
290
+ questions = candidate
291
+ break
292
+ normalized = _normalize_questions(questions)
293
+ props = dict(base)
294
+ props["questions"] = normalized
295
+ return request_id, props
296
+
297
+
298
+ def _extract_question_option_label(option: Any) -> Optional[str]:
299
+ if isinstance(option, str):
300
+ return option.strip() or None
301
+ if isinstance(option, dict):
302
+ for key in ("label", "text", "value", "name", "id"):
303
+ value = option.get(key)
304
+ if isinstance(value, str) and value.strip():
305
+ return value.strip()
306
+ return None
307
+
308
+
309
+ def _extract_question_options(question: dict[str, Any]) -> list[str]:
310
+ for key in ("options", "choices"):
311
+ raw = question.get(key)
312
+ if isinstance(raw, list):
313
+ options = []
314
+ for option in raw:
315
+ label = _extract_question_option_label(option)
316
+ if label:
317
+ options.append(label)
318
+ return options
319
+ return []
320
+
321
+
322
+ def _auto_answers_for_questions(
323
+ questions: list[dict[str, Any]], policy: str
324
+ ) -> list[list[str]]:
325
+ if policy == "auto_unanswered":
326
+ return [[] for _ in questions]
327
+ answers: list[list[str]] = []
328
+ for question in questions:
329
+ options = _extract_question_options(question)
330
+ if options:
331
+ answers.append([options[0]])
332
+ else:
333
+ answers.append([])
334
+ return answers
335
+
336
+
337
+ def _normalize_question_answers(
338
+ answers: Any, *, question_count: int
339
+ ) -> list[list[str]]:
340
+ if not isinstance(answers, list):
341
+ normalized: list[list[str]] = []
342
+ elif answers and all(isinstance(item, str) for item in answers):
343
+ normalized = [[item for item in answers if isinstance(item, str)]]
344
+ else:
345
+ normalized = []
346
+ for item in answers:
347
+ if isinstance(item, list):
348
+ normalized.append([entry for entry in item if isinstance(entry, str)])
349
+ elif isinstance(item, str):
350
+ normalized.append([item])
351
+ else:
352
+ normalized.append([])
353
+ if question_count <= 0:
354
+ return normalized
355
+ if len(normalized) < question_count:
356
+ normalized.extend([[] for _ in range(question_count - len(normalized))])
357
+ return normalized[:question_count]
358
+
359
+
360
+ def _summarize_question_answers(answers: list[list[str]]) -> list[str]:
361
+ summary: list[str] = []
362
+ for answer in answers:
363
+ if not answer:
364
+ summary.append("")
365
+ elif len(answer) == 1:
366
+ summary.append(answer[0])
367
+ else:
368
+ summary.append(", ".join(answer))
369
+ return summary
370
+
371
+
143
372
  def format_permission_prompt(payload: dict[str, Any]) -> str:
144
373
  lines = ["Approval required"]
145
374
  reason = payload.get("reason") or payload.get("message") or payload.get("detail")
@@ -178,10 +407,259 @@ def map_approval_policy_to_permission(
178
407
  return default
179
408
 
180
409
 
410
+ def _normalize_permission_decision(decision: Any) -> str:
411
+ decision_norm = str(decision or "").strip().lower()
412
+ if decision_norm in (
413
+ "always",
414
+ "accept_session",
415
+ "accept-session",
416
+ "allow_session",
417
+ "allow-session",
418
+ "session",
419
+ "session_allow",
420
+ ):
421
+ return OPENCODE_PERMISSION_ALWAYS
422
+ if decision_norm in (
423
+ "allow",
424
+ "approved",
425
+ "approve",
426
+ "accept",
427
+ "accepted",
428
+ "yes",
429
+ "y",
430
+ "ok",
431
+ "okay",
432
+ "true",
433
+ "1",
434
+ ):
435
+ return OPENCODE_PERMISSION_ONCE
436
+ if decision_norm in (
437
+ "deny",
438
+ "reject",
439
+ "decline",
440
+ "declined",
441
+ "cancel",
442
+ "no",
443
+ "n",
444
+ "false",
445
+ "0",
446
+ ):
447
+ return OPENCODE_PERMISSION_REJECT
448
+ return OPENCODE_PERMISSION_REJECT
449
+
450
+
451
+ def _permission_policy_reply(policy: str) -> str:
452
+ if policy == PERMISSION_ALLOW:
453
+ return OPENCODE_PERMISSION_ONCE
454
+ return OPENCODE_PERMISSION_REJECT
455
+
456
+
457
+ def _coerce_int(value: Any) -> Optional[int]:
458
+ if isinstance(value, bool):
459
+ return None
460
+ try:
461
+ return int(value)
462
+ except Exception:
463
+ return None
464
+
465
+
466
+ def _extract_usage_field(
467
+ payload: dict[str, Any], keys: tuple[str, ...]
468
+ ) -> Optional[int]:
469
+ for key in keys:
470
+ if key in payload:
471
+ value = _coerce_int(payload.get(key))
472
+ if value is not None:
473
+ return value
474
+ return None
475
+
476
+
477
+ def _flatten_opencode_tokens(tokens: dict[str, Any]) -> Optional[dict[str, Any]]:
478
+ usage: dict[str, Any] = {}
479
+ total_tokens = _coerce_int(tokens.get("total"))
480
+ if total_tokens is not None:
481
+ usage["totalTokens"] = total_tokens
482
+ input_tokens = _coerce_int(tokens.get("input"))
483
+ if input_tokens is not None:
484
+ usage["inputTokens"] = input_tokens
485
+ output_tokens = _coerce_int(tokens.get("output"))
486
+ if output_tokens is not None:
487
+ usage["outputTokens"] = output_tokens
488
+ reasoning_tokens = _coerce_int(tokens.get("reasoning"))
489
+ if reasoning_tokens is not None:
490
+ usage["reasoningTokens"] = reasoning_tokens
491
+ cache = tokens.get("cache")
492
+ if isinstance(cache, dict):
493
+ cached_read = _coerce_int(cache.get("read"))
494
+ if cached_read is not None:
495
+ usage["cachedInputTokens"] = cached_read
496
+ cached_write = _coerce_int(cache.get("write"))
497
+ if cached_write is not None:
498
+ usage["cacheWriteTokens"] = cached_write
499
+ if "totalTokens" not in usage:
500
+ components = [
501
+ usage.get("inputTokens"),
502
+ usage.get("outputTokens"),
503
+ usage.get("reasoningTokens"),
504
+ usage.get("cachedInputTokens"),
505
+ usage.get("cacheWriteTokens"),
506
+ ]
507
+ numeric = [value for value in components if isinstance(value, int)]
508
+ if numeric:
509
+ usage["totalTokens"] = sum(numeric)
510
+ return usage or None
511
+
512
+
513
+ def _extract_usage_payload(payload: Any) -> Optional[dict[str, Any]]:
514
+ if not isinstance(payload, dict):
515
+ return None
516
+ containers = [payload]
517
+ info = payload.get("info")
518
+ if isinstance(info, dict):
519
+ containers.append(info)
520
+ properties = payload.get("properties")
521
+ if isinstance(properties, dict):
522
+ containers.append(properties)
523
+ prop_info = properties.get("info")
524
+ if isinstance(prop_info, dict):
525
+ containers.append(prop_info)
526
+ response = payload.get("response")
527
+ if isinstance(response, dict):
528
+ containers.append(response)
529
+ for container in containers:
530
+ for key in (
531
+ "usage",
532
+ "token_usage",
533
+ "tokenUsage",
534
+ "usage_stats",
535
+ "usageStats",
536
+ "stats",
537
+ ):
538
+ usage = container.get(key)
539
+ if isinstance(usage, dict):
540
+ return usage
541
+ tokens = container.get("tokens")
542
+ if isinstance(tokens, dict):
543
+ flattened = _flatten_opencode_tokens(tokens)
544
+ if flattened:
545
+ return flattened
546
+ return None
547
+
548
+
549
+ def _extract_total_tokens(usage: dict[str, Any]) -> Optional[int]:
550
+ total = _extract_usage_field(usage, _OPENCODE_USAGE_TOTAL_KEYS)
551
+ if total is not None:
552
+ return total
553
+ input_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_INPUT_KEYS) or 0
554
+ cached_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_CACHED_KEYS) or 0
555
+ output_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_OUTPUT_KEYS) or 0
556
+ reasoning_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_REASONING_KEYS) or 0
557
+ if input_tokens or cached_tokens or output_tokens or reasoning_tokens:
558
+ return input_tokens + cached_tokens + output_tokens + reasoning_tokens
559
+ return None
560
+
561
+
562
+ def _extract_usage_details(usage: dict[str, Any]) -> dict[str, int]:
563
+ details: dict[str, int] = {}
564
+ input_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_INPUT_KEYS)
565
+ if input_tokens is not None:
566
+ details["inputTokens"] = input_tokens
567
+ cached_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_CACHED_KEYS)
568
+ if cached_tokens is not None:
569
+ details["cachedInputTokens"] = cached_tokens
570
+ output_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_OUTPUT_KEYS)
571
+ if output_tokens is not None:
572
+ details["outputTokens"] = output_tokens
573
+ reasoning_tokens = _extract_usage_field(usage, _OPENCODE_USAGE_REASONING_KEYS)
574
+ if reasoning_tokens is not None:
575
+ details["reasoningTokens"] = reasoning_tokens
576
+ return details
577
+
578
+
579
+ def _extract_context_window(
580
+ payload: Any, usage: Optional[dict[str, Any]]
581
+ ) -> Optional[int]:
582
+ containers: list[dict[str, Any]] = []
583
+ if isinstance(payload, dict):
584
+ containers.append(payload)
585
+ info = payload.get("info")
586
+ if isinstance(info, dict):
587
+ containers.append(info)
588
+ properties = payload.get("properties")
589
+ if isinstance(properties, dict):
590
+ containers.append(properties)
591
+ prop_info = properties.get("info")
592
+ if isinstance(prop_info, dict):
593
+ containers.append(prop_info)
594
+ response = payload.get("response")
595
+ if isinstance(response, dict):
596
+ containers.append(response)
597
+ response_info = response.get("info")
598
+ if isinstance(response_info, dict):
599
+ containers.append(response_info)
600
+ response_props = response.get("properties")
601
+ if isinstance(response_props, dict):
602
+ containers.append(response_props)
603
+ response_prop_info = response_props.get("info")
604
+ if isinstance(response_prop_info, dict):
605
+ containers.append(response_prop_info)
606
+ for key in ("model", "modelInfo", "model_info", "modelConfig", "model_config"):
607
+ model = payload.get(key)
608
+ if isinstance(model, dict):
609
+ containers.append(model)
610
+ if isinstance(usage, dict):
611
+ containers.insert(0, usage)
612
+ for container in containers:
613
+ for key in _OPENCODE_CONTEXT_WINDOW_KEYS:
614
+ value = _coerce_int(container.get(key))
615
+ if value is not None and value > 0:
616
+ return value
617
+ return None
618
+
619
+
620
+ def _extract_status_type(payload: Any) -> Optional[str]:
621
+ if not isinstance(payload, dict):
622
+ return None
623
+ for container in (
624
+ payload,
625
+ payload.get("status"),
626
+ payload.get("info"),
627
+ payload.get("properties"),
628
+ ):
629
+ if not isinstance(container, dict):
630
+ continue
631
+ if container is payload:
632
+ status = container.get("status")
633
+ else:
634
+ status = container
635
+ if isinstance(status, dict):
636
+ value = status.get("type") or status.get("status")
637
+ else:
638
+ value = status
639
+ if isinstance(value, str) and value:
640
+ return value
641
+ properties = payload.get("properties")
642
+ if isinstance(properties, dict):
643
+ status = properties.get("status")
644
+ if isinstance(status, dict):
645
+ value = status.get("type") or status.get("status")
646
+ if isinstance(value, str) and value:
647
+ return value
648
+ return None
649
+
650
+
651
+ def _status_is_idle(status_type: Optional[str]) -> bool:
652
+ if not status_type:
653
+ return False
654
+ return status_type.strip().lower() in _OPENCODE_IDLE_STATUS_VALUES
655
+
656
+
181
657
  async def opencode_missing_env(
182
658
  client: Any,
183
659
  workspace_root: str,
184
660
  model_payload: Optional[dict[str, str]],
661
+ *,
662
+ env: Optional[MutableMapping[str, str]] = None,
185
663
  ) -> list[str]:
186
664
  if not model_payload:
187
665
  return []
@@ -201,7 +679,7 @@ async def opencode_missing_env(
201
679
  providers = [entry for entry in payload if isinstance(entry, dict)]
202
680
  for provider in providers:
203
681
  pid = provider.get("id") or provider.get("providerID")
204
- if pid != provider_id:
682
+ if not pid or pid != provider_id:
205
683
  continue
206
684
  if _provider_has_auth(pid, workspace_root):
207
685
  return []
@@ -211,12 +689,20 @@ async def opencode_missing_env(
211
689
  missing = [
212
690
  key
213
691
  for key in env_keys
214
- if isinstance(key, str) and key and not os.getenv(key)
692
+ if isinstance(key, str) and key and not _get_env_value(key, env)
215
693
  ]
216
694
  return missing
217
695
  return []
218
696
 
219
697
 
698
+ def _get_env_value(
699
+ key: str, env: Optional[MutableMapping[str, str]] = None
700
+ ) -> Optional[str]:
701
+ if env is not None:
702
+ return env.get(key)
703
+ return os.getenv(key)
704
+
705
+
220
706
  def _provider_has_auth(provider_id: str, workspace_root: str) -> bool:
221
707
  auth_path = _find_opencode_auth_path(workspace_root)
222
708
  if auth_path is None or not auth_path.exists():
@@ -236,7 +722,7 @@ def _find_opencode_auth_path(workspace_root: str) -> Optional[Path]:
236
722
  if not data_home:
237
723
  home = os.getenv("HOME")
238
724
  if not home:
239
- inferred = _infer_home_from_workspace(workspace_root)
725
+ inferred = infer_home_from_workspace(workspace_root)
240
726
  if inferred is None:
241
727
  return None
242
728
  data_home = str(inferred / ".local" / "share")
@@ -245,40 +731,25 @@ def _find_opencode_auth_path(workspace_root: str) -> Optional[Path]:
245
731
  return Path(data_home) / "opencode" / "auth.json"
246
732
 
247
733
 
248
- def _infer_home_from_workspace(workspace_root: str) -> Optional[Path]:
249
- resolved = Path(workspace_root).resolve()
250
- parts = resolved.parts
251
- if (
252
- len(parts) >= 6
253
- and parts[0] == os.path.sep
254
- and parts[1] == "System"
255
- and parts[2] == "Volumes"
256
- and parts[3] == "Data"
257
- and parts[4] == "Users"
258
- ):
259
- return Path(parts[0]) / parts[1] / parts[2] / parts[3] / parts[4] / parts[5]
260
- if (
261
- len(parts) >= 3
262
- and parts[0] == os.path.sep
263
- and parts[1]
264
- in (
265
- "Users",
266
- "home",
267
- )
268
- ):
269
- return Path(parts[0]) / parts[1] / parts[2]
270
- return None
271
-
272
-
273
734
  async def collect_opencode_output_from_events(
274
- events: AsyncIterator[SSEEvent],
735
+ events: Optional[AsyncIterator[SSEEvent]] = None,
275
736
  *,
276
737
  session_id: str,
738
+ model_payload: Optional[dict[str, str]] = None,
739
+ progress_session_ids: Optional[set[str]] = None,
277
740
  permission_policy: str = PERMISSION_ALLOW,
278
741
  permission_handler: Optional[PermissionHandler] = None,
742
+ question_policy: str = "ignore",
743
+ question_handler: Optional[QuestionHandler] = None,
279
744
  should_stop: Optional[Callable[[], bool]] = None,
280
745
  respond_permission: Optional[Callable[[str, str], Awaitable[None]]] = None,
746
+ reply_question: Optional[Callable[[str, list[list[str]]], Awaitable[None]]] = None,
747
+ reject_question: Optional[Callable[[str], Awaitable[None]]] = None,
281
748
  part_handler: Optional[PartHandler] = None,
749
+ event_stream_factory: Optional[Callable[[], AsyncIterator[SSEEvent]]] = None,
750
+ session_fetcher: Optional[Callable[[], Awaitable[Any]]] = None,
751
+ provider_fetcher: Optional[Callable[[], Awaitable[Any]]] = None,
752
+ stall_timeout_seconds: Optional[float] = _OPENCODE_STREAM_STALL_TIMEOUT_SECONDS,
282
753
  ) -> OpenCodeTurnOutput:
283
754
  text_parts: list[str] = []
284
755
  part_lengths: dict[str, int] = {}
@@ -288,6 +759,20 @@ async def collect_opencode_output_from_events(
288
759
  message_roles_seen = False
289
760
  last_role_seen: Optional[str] = None
290
761
  pending_text: dict[str, list[str]] = {}
762
+ fallback_message: Optional[tuple[Optional[str], Optional[str], str]] = None
763
+ last_usage_total: Optional[int] = None
764
+ last_context_window: Optional[int] = None
765
+ part_types: dict[str, str] = {}
766
+ seen_question_request_ids: set[tuple[Optional[str], str]] = set()
767
+ logged_permission_errors: set[str] = set()
768
+ normalized_question_policy = _normalize_question_policy(question_policy)
769
+ logger = logging.getLogger(__name__)
770
+ providers_cache: Optional[list[dict[str, Any]]] = None
771
+ context_window_cache: dict[str, Optional[int]] = {}
772
+ session_model_ids: Optional[tuple[Optional[str], Optional[str]]] = None
773
+ default_model_ids = (
774
+ _extract_model_ids(model_payload) if isinstance(model_payload, dict) else None
775
+ )
291
776
 
292
777
  def _message_id_from_info(info: Any) -> Optional[str]:
293
778
  if not isinstance(info, dict):
@@ -361,106 +846,647 @@ async def collect_opencode_output_from_events(
361
846
  text_parts.extend(pending)
362
847
  pending_text.clear()
363
848
 
364
- async for event in events:
365
- if should_stop is not None and should_stop():
849
+ async def _resolve_session_model_ids() -> tuple[Optional[str], Optional[str]]:
850
+ nonlocal session_model_ids
851
+ if session_model_ids is not None:
852
+ return session_model_ids
853
+ resolved_ids: Optional[tuple[Optional[str], Optional[str]]] = None
854
+ if session_fetcher is not None:
855
+ try:
856
+ payload = await session_fetcher()
857
+ resolved_ids = _extract_model_ids(payload)
858
+ except Exception:
859
+ resolved_ids = None
860
+ # If we failed to resolve model ids from the session (including the empty
861
+ # tuple case), fall back to the caller-provided model payload so we can
862
+ # still backfill usage metadata.
863
+ if not resolved_ids or all(value is None for value in resolved_ids):
864
+ resolved_ids = default_model_ids
865
+ session_model_ids = resolved_ids or (None, None)
866
+ return session_model_ids
867
+
868
+ async def _resolve_context_window_from_providers(
869
+ provider_id: Optional[str], model_id: Optional[str]
870
+ ) -> Optional[int]:
871
+ nonlocal providers_cache
872
+ if not provider_id or not model_id:
873
+ return None
874
+ cache_key = f"{provider_id}/{model_id}"
875
+ if cache_key in context_window_cache:
876
+ return context_window_cache[cache_key]
877
+ if provider_fetcher is None:
878
+ context_window_cache[cache_key] = None
879
+ return None
880
+ if providers_cache is None:
881
+ try:
882
+ payload = await provider_fetcher()
883
+ except Exception:
884
+ context_window_cache[cache_key] = None
885
+ return None
886
+ providers: list[dict[str, Any]] = []
887
+ if isinstance(payload, dict):
888
+ raw_providers = payload.get("providers")
889
+ if isinstance(raw_providers, list):
890
+ providers = [
891
+ entry for entry in raw_providers if isinstance(entry, dict)
892
+ ]
893
+ elif isinstance(payload, list):
894
+ providers = [entry for entry in payload if isinstance(entry, dict)]
895
+ providers_cache = providers
896
+ context_window = None
897
+ for provider in providers_cache or []:
898
+ pid = provider.get("id") or provider.get("providerID")
899
+ if pid != provider_id:
900
+ continue
901
+ models = provider.get("models")
902
+ model_entry = None
903
+ if isinstance(models, dict):
904
+ candidate = models.get(model_id)
905
+ if isinstance(candidate, dict):
906
+ model_entry = candidate
907
+ elif isinstance(models, list):
908
+ for entry in models:
909
+ if not isinstance(entry, dict):
910
+ continue
911
+ entry_id = entry.get("id") or entry.get("modelID")
912
+ if entry_id == model_id:
913
+ model_entry = entry
914
+ break
915
+ if isinstance(model_entry, dict):
916
+ limit = model_entry.get("limit") or model_entry.get("limits")
917
+ if isinstance(limit, dict):
918
+ for key in _OPENCODE_MODEL_CONTEXT_KEYS:
919
+ value = _coerce_int(limit.get(key))
920
+ if value is not None and value > 0:
921
+ context_window = value
922
+ break
923
+ if context_window is None:
924
+ for key in _OPENCODE_MODEL_CONTEXT_KEYS:
925
+ value = _coerce_int(model_entry.get(key))
926
+ if value is not None and value > 0:
927
+ context_window = value
928
+ break
929
+ if context_window is None:
930
+ limit = provider.get("limit") or provider.get("limits")
931
+ if isinstance(limit, dict):
932
+ for key in _OPENCODE_MODEL_CONTEXT_KEYS:
933
+ value = _coerce_int(limit.get(key))
934
+ if value is not None and value > 0:
935
+ context_window = value
936
+ break
366
937
  break
367
- raw = event.data or ""
368
- try:
369
- payload = json.loads(raw) if raw else {}
370
- except json.JSONDecodeError:
371
- payload = {}
372
- event_session_id = extract_session_id(payload)
373
- if not event_session_id or event_session_id != session_id:
374
- continue
375
- if event.event == "permission.asked":
376
- request_id, props = _extract_permission_request(payload)
377
- if request_id and respond_permission is not None:
378
- reply = PERMISSION_DENY
379
- if permission_policy == PERMISSION_ALLOW:
380
- reply = PERMISSION_ALLOW
381
- elif (
382
- permission_policy == PERMISSION_ASK
383
- and permission_handler is not None
938
+ context_window_cache[cache_key] = context_window
939
+ return context_window
940
+
941
+ stream_factory = event_stream_factory
942
+ if events is None and stream_factory is None:
943
+ raise ValueError("events or event_stream_factory must be provided")
944
+
945
+ def _new_stream() -> AsyncIterator[SSEEvent]:
946
+ if stream_factory is not None:
947
+ return stream_factory()
948
+ if events is None:
949
+ raise ValueError("events or event_stream_factory must be provided")
950
+ return events
951
+
952
+ async def _close_stream(iterator: AsyncIterator[SSEEvent]) -> None:
953
+ aclose = getattr(iterator, "aclose", None)
954
+ if aclose is None:
955
+ return
956
+ with suppress(Exception):
957
+ await aclose()
958
+
959
+ stream_iter = _new_stream().__aiter__()
960
+ last_relevant_event_at = time.monotonic()
961
+ last_primary_completion_at: Optional[float] = None
962
+ reconnect_attempts = 0
963
+ can_reconnect = (
964
+ event_stream_factory is not None and stall_timeout_seconds is not None
965
+ )
966
+
967
+ try:
968
+ while True:
969
+ if should_stop is not None and should_stop():
970
+ break
971
+ try:
972
+ if can_reconnect and stall_timeout_seconds is not None:
973
+ event = await asyncio.wait_for(
974
+ stream_iter.__anext__(), timeout=stall_timeout_seconds
975
+ )
976
+ else:
977
+ event = await stream_iter.__anext__()
978
+ except StopAsyncIteration:
979
+ break
980
+ except asyncio.TimeoutError:
981
+ now = time.monotonic()
982
+ status_type = None
983
+ if session_fetcher is not None:
984
+ try:
985
+ payload = await session_fetcher()
986
+ status_type = _extract_status_type(payload)
987
+ except Exception as exc:
988
+ log_event(
989
+ logger,
990
+ logging.WARNING,
991
+ "opencode.session.poll_failed",
992
+ session_id=session_id,
993
+ exc=exc,
994
+ )
995
+ idle_seconds = now - last_relevant_event_at
996
+ if _status_is_idle(status_type):
997
+ log_event(
998
+ logger,
999
+ logging.INFO,
1000
+ "opencode.stream.stalled.session_idle",
1001
+ session_id=session_id,
1002
+ status_type=status_type,
1003
+ idle_seconds=idle_seconds,
1004
+ )
1005
+ if not text_parts and pending_text:
1006
+ _flush_all_pending_text()
1007
+ break
1008
+ if last_primary_completion_at is not None:
1009
+ log_event(
1010
+ logger,
1011
+ logging.INFO,
1012
+ "opencode.stream.stalled.after_completion",
1013
+ session_id=session_id,
1014
+ status_type=status_type,
1015
+ idle_seconds=idle_seconds,
1016
+ )
1017
+ if not can_reconnect:
1018
+ break
1019
+ backoff_index = min(
1020
+ reconnect_attempts,
1021
+ len(_OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS) - 1,
1022
+ )
1023
+ backoff = _OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS[backoff_index]
1024
+ reconnect_attempts += 1
1025
+ log_event(
1026
+ logger,
1027
+ logging.WARNING,
1028
+ "opencode.stream.stalled.reconnecting",
1029
+ session_id=session_id,
1030
+ idle_seconds=idle_seconds,
1031
+ backoff_seconds=backoff,
1032
+ status_type=status_type,
1033
+ attempts=reconnect_attempts,
1034
+ )
1035
+ await _close_stream(stream_iter)
1036
+ await asyncio.sleep(backoff)
1037
+ stream_iter = _new_stream().__aiter__()
1038
+ last_relevant_event_at = now
1039
+ continue
1040
+ now = time.monotonic()
1041
+ raw = event.data or ""
1042
+ try:
1043
+ payload = json.loads(raw) if raw else {}
1044
+ except json.JSONDecodeError:
1045
+ payload = {}
1046
+ event_session_id = extract_session_id(payload)
1047
+ is_relevant = False
1048
+ if event_session_id:
1049
+ if progress_session_ids is None:
1050
+ is_relevant = event_session_id == session_id
1051
+ else:
1052
+ is_relevant = event_session_id in progress_session_ids
1053
+ if not is_relevant:
1054
+ if (
1055
+ stall_timeout_seconds is not None
1056
+ and now - last_relevant_event_at > stall_timeout_seconds
384
1057
  ):
1058
+ idle_seconds = now - last_relevant_event_at
1059
+ last_relevant_event_at = now
1060
+ status_type = None
1061
+ if session_fetcher is not None:
1062
+ try:
1063
+ payload = await session_fetcher()
1064
+ status_type = _extract_status_type(payload)
1065
+ except Exception as exc:
1066
+ log_event(
1067
+ logger,
1068
+ logging.WARNING,
1069
+ "opencode.session.poll_failed",
1070
+ session_id=session_id,
1071
+ exc=exc,
1072
+ )
1073
+ if _status_is_idle(status_type):
1074
+ log_event(
1075
+ logger,
1076
+ logging.INFO,
1077
+ "opencode.stream.stalled.session_idle",
1078
+ session_id=session_id,
1079
+ status_type=status_type,
1080
+ idle_seconds=idle_seconds,
1081
+ )
1082
+ if not text_parts and pending_text:
1083
+ _flush_all_pending_text()
1084
+ break
1085
+ if last_primary_completion_at is not None:
1086
+ log_event(
1087
+ logger,
1088
+ logging.INFO,
1089
+ "opencode.stream.stalled.after_completion",
1090
+ session_id=session_id,
1091
+ status_type=status_type,
1092
+ idle_seconds=idle_seconds,
1093
+ )
1094
+ if not can_reconnect:
1095
+ break
1096
+ backoff_index = min(
1097
+ reconnect_attempts,
1098
+ len(_OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS) - 1,
1099
+ )
1100
+ backoff = _OPENCODE_STREAM_RECONNECT_BACKOFF_SECONDS[backoff_index]
1101
+ reconnect_attempts += 1
1102
+ log_event(
1103
+ logger,
1104
+ logging.WARNING,
1105
+ "opencode.stream.stalled.reconnecting",
1106
+ session_id=session_id,
1107
+ idle_seconds=idle_seconds,
1108
+ backoff_seconds=backoff,
1109
+ status_type=status_type,
1110
+ attempts=reconnect_attempts,
1111
+ )
1112
+ await _close_stream(stream_iter)
1113
+ await asyncio.sleep(backoff)
1114
+ stream_iter = _new_stream().__aiter__()
1115
+ continue
1116
+ last_relevant_event_at = now
1117
+ reconnect_attempts = 0
1118
+ is_primary_session = event_session_id == session_id
1119
+ if event.event == "question.asked":
1120
+ request_id, props = _extract_question_request(payload)
1121
+ questions = props.get("questions") if isinstance(props, dict) else []
1122
+ question_count = len(questions) if isinstance(questions, list) else 0
1123
+ log_event(
1124
+ logger,
1125
+ logging.INFO,
1126
+ "opencode.question.asked",
1127
+ request_id=request_id,
1128
+ question_count=question_count,
1129
+ session_id=event_session_id,
1130
+ )
1131
+ if not request_id:
1132
+ continue
1133
+ dedupe_key = (event_session_id, request_id)
1134
+ if dedupe_key in seen_question_request_ids:
1135
+ continue
1136
+ seen_question_request_ids.add(dedupe_key)
1137
+ if question_handler is not None:
385
1138
  try:
386
- decision = await permission_handler(request_id, props)
387
- except Exception:
388
- decision = "reject"
389
- decision_norm = str(decision or "").strip().lower()
390
- if decision_norm in ("allow", "approved", "approve"):
391
- reply = PERMISSION_ALLOW
392
- elif decision_norm in ("deny", "reject", "cancel"):
393
- reply = PERMISSION_DENY
394
- try:
395
- await respond_permission(request_id, reply)
396
- except Exception:
397
- pass
398
- if event.event == "session.error":
399
- error = _extract_error_text(payload) or "OpenCode session error"
400
- break
401
- if event.event in ("message.updated", "message.completed"):
402
- msg_id, role = _register_message_role(payload)
403
- if role == "assistant":
404
- _flush_pending_text(msg_id)
405
- if event.event == "message.part.updated":
406
- properties = (
407
- payload.get("properties") if isinstance(payload, dict) else None
408
- )
409
- if isinstance(properties, dict):
410
- part = properties.get("part")
411
- delta = properties.get("delta")
412
- else:
413
- part = payload.get("part")
414
- delta = payload.get("delta")
415
- part_dict = part if isinstance(part, dict) else None
416
- part_type = part_dict.get("type") if part_dict else None
417
- part_ignored = bool(part_dict.get("ignored")) if part_dict else False
418
- part_message_id = _message_id_from_part(part_dict)
419
- if isinstance(delta, dict):
420
- delta_text = delta.get("text")
421
- elif isinstance(delta, str):
422
- delta_text = delta
423
- else:
424
- delta_text = None
425
- if isinstance(delta_text, str) and delta_text:
426
- if part_type == "text" and not part_ignored:
427
- _append_text_for_message(part_message_id, delta_text)
428
- elif part_type == "reasoning":
429
- pass
430
- elif part_handler and part_dict and part_type:
431
- await part_handler(part_type, part_dict, delta_text)
432
- elif (
433
- isinstance(part_dict, dict) and part_type == "text" and not part_ignored
434
- ):
435
- text = part_dict.get("text")
436
- if isinstance(text, str) and text:
437
- part_id = part_dict.get("id") or part_dict.get("partId")
438
- if isinstance(part_id, str) and part_id:
439
- last_len = part_lengths.get(part_id, 0)
440
- if len(text) > last_len:
441
- _append_text_for_message(part_message_id, text[last_len:])
442
- part_lengths[part_id] = len(text)
1139
+ answers = await question_handler(request_id, props)
1140
+ except Exception as exc:
1141
+ log_event(
1142
+ logger,
1143
+ logging.WARNING,
1144
+ "opencode.question.auto_reply_failed",
1145
+ request_id=request_id,
1146
+ session_id=event_session_id,
1147
+ exc=exc,
1148
+ )
1149
+ if reject_question is not None:
1150
+ try:
1151
+ await reject_question(request_id)
1152
+ except Exception:
1153
+ pass
1154
+ continue
1155
+ if answers is None:
1156
+ if reject_question is not None:
1157
+ try:
1158
+ await reject_question(request_id)
1159
+ except Exception:
1160
+ pass
1161
+ continue
1162
+ normalized_answers = _normalize_question_answers(
1163
+ answers, question_count=question_count
1164
+ )
1165
+ if reply_question is not None:
1166
+ try:
1167
+ await reply_question(request_id, normalized_answers)
1168
+ log_event(
1169
+ logger,
1170
+ logging.INFO,
1171
+ "opencode.question.replied",
1172
+ request_id=request_id,
1173
+ question_count=question_count,
1174
+ session_id=event_session_id,
1175
+ mode="handler",
1176
+ )
1177
+ except Exception as exc:
1178
+ log_event(
1179
+ logger,
1180
+ logging.WARNING,
1181
+ "opencode.question.auto_reply_failed",
1182
+ request_id=request_id,
1183
+ session_id=event_session_id,
1184
+ exc=exc,
1185
+ )
1186
+ continue
1187
+ if normalized_question_policy == "ignore":
1188
+ continue
1189
+ if normalized_question_policy == "reject":
1190
+ if reject_question is not None:
1191
+ try:
1192
+ await reject_question(request_id)
1193
+ except Exception as exc:
1194
+ log_event(
1195
+ logger,
1196
+ logging.WARNING,
1197
+ "opencode.question.auto_reply_failed",
1198
+ request_id=request_id,
1199
+ session_id=event_session_id,
1200
+ exc=exc,
1201
+ )
1202
+ continue
1203
+ auto_answers = _auto_answers_for_questions(
1204
+ questions if isinstance(questions, list) else [],
1205
+ normalized_question_policy,
1206
+ )
1207
+ normalized_answers = _normalize_question_answers(
1208
+ auto_answers, question_count=question_count
1209
+ )
1210
+ if reply_question is not None:
1211
+ try:
1212
+ await reply_question(request_id, normalized_answers)
1213
+ log_event(
1214
+ logger,
1215
+ logging.INFO,
1216
+ "opencode.question.auto_replied",
1217
+ request_id=request_id,
1218
+ question_count=question_count,
1219
+ session_id=event_session_id,
1220
+ policy=normalized_question_policy,
1221
+ answers=_summarize_question_answers(normalized_answers),
1222
+ )
1223
+ except Exception as exc:
1224
+ log_event(
1225
+ logger,
1226
+ logging.WARNING,
1227
+ "opencode.question.auto_reply_failed",
1228
+ request_id=request_id,
1229
+ session_id=event_session_id,
1230
+ exc=exc,
1231
+ )
1232
+ continue
1233
+ if event.event == "permission.asked":
1234
+ request_id, props = _extract_permission_request(payload)
1235
+ if request_id and respond_permission is not None:
1236
+ if (
1237
+ permission_policy == PERMISSION_ASK
1238
+ and permission_handler is not None
1239
+ ):
1240
+ try:
1241
+ decision = await permission_handler(request_id, props)
1242
+ except Exception:
1243
+ decision = OPENCODE_PERMISSION_REJECT
1244
+ reply = _normalize_permission_decision(decision)
443
1245
  else:
444
- if last_full_text and text.startswith(last_full_text):
445
- _append_text_for_message(
446
- part_message_id, text[len(last_full_text) :]
1246
+ reply = _permission_policy_reply(permission_policy)
1247
+ try:
1248
+ await respond_permission(request_id, reply)
1249
+ except Exception as exc:
1250
+ status_code = None
1251
+ body_preview = None
1252
+ if isinstance(exc, httpx.HTTPStatusError):
1253
+ status_code = exc.response.status_code
1254
+ body_preview = (exc.response.text or "").strip()[
1255
+ :200
1256
+ ] or None
1257
+ if (
1258
+ status_code is not None
1259
+ and 400 <= status_code < 500
1260
+ and request_id not in logged_permission_errors
1261
+ ):
1262
+ logged_permission_errors.add(request_id)
1263
+ log_event(
1264
+ logger,
1265
+ logging.ERROR,
1266
+ "opencode.permission.reply_failed",
1267
+ request_id=request_id,
1268
+ reply=reply,
1269
+ status_code=status_code,
1270
+ body_preview=body_preview,
1271
+ session_id=event_session_id,
1272
+ )
1273
+ else:
1274
+ log_event(
1275
+ logger,
1276
+ logging.ERROR,
1277
+ "opencode.permission.reply_failed",
1278
+ request_id=request_id,
1279
+ reply=reply,
1280
+ session_id=event_session_id,
1281
+ exc=exc,
1282
+ )
1283
+ if is_primary_session:
1284
+ detail = body_preview or _extract_error_text(payload)
1285
+ error = "OpenCode permission reply failed"
1286
+ if status_code is not None:
1287
+ error = f"{error} ({status_code})"
1288
+ if detail:
1289
+ error = f"{error}: {detail}"
1290
+ break
1291
+ if event.event == "session.error":
1292
+ if is_primary_session:
1293
+ error = _extract_error_text(payload) or "OpenCode session error"
1294
+ break
1295
+ continue
1296
+ if event.event in ("message.updated", "message.completed"):
1297
+ if is_primary_session:
1298
+ msg_id, role = _register_message_role(payload)
1299
+ if role == "assistant":
1300
+ _flush_pending_text(msg_id)
1301
+ if event.event == "message.part.updated":
1302
+ properties = (
1303
+ payload.get("properties") if isinstance(payload, dict) else None
1304
+ )
1305
+ if isinstance(properties, dict):
1306
+ part = properties.get("part")
1307
+ delta = properties.get("delta")
1308
+ else:
1309
+ part = payload.get("part")
1310
+ delta = payload.get("delta")
1311
+ part_dict = part if isinstance(part, dict) else None
1312
+ part_with_session = None
1313
+ if isinstance(part_dict, dict):
1314
+ part_with_session = dict(part_dict)
1315
+ part_with_session["sessionID"] = event_session_id
1316
+ part_type = part_dict.get("type") if part_dict else None
1317
+ part_ignored = bool(part_dict.get("ignored")) if part_dict else False
1318
+ part_message_id = _message_id_from_part(part_dict)
1319
+ part_id = None
1320
+ if part_dict:
1321
+ part_id = part_dict.get("id") or part_dict.get("partId")
1322
+ if (
1323
+ isinstance(part_id, str)
1324
+ and part_id
1325
+ and isinstance(part_type, str)
1326
+ ):
1327
+ part_types[part_id] = part_type
1328
+ elif (
1329
+ isinstance(part_id, str)
1330
+ and part_id
1331
+ and not isinstance(part_type, str)
1332
+ and part_id in part_types
1333
+ ):
1334
+ part_type = part_types[part_id]
1335
+ if isinstance(delta, dict):
1336
+ delta_text = delta.get("text")
1337
+ elif isinstance(delta, str):
1338
+ delta_text = delta
1339
+ else:
1340
+ delta_text = None
1341
+ if isinstance(delta_text, str) and delta_text:
1342
+ if part_type == "reasoning":
1343
+ if part_handler and part_dict:
1344
+ await part_handler(
1345
+ "reasoning", part_with_session or part_dict, delta_text
1346
+ )
1347
+ elif part_type in (None, "text") and not part_ignored:
1348
+ if not is_primary_session:
1349
+ continue
1350
+ _append_text_for_message(part_message_id, delta_text)
1351
+ # Update dedupe bookkeeping for text deltas to prevent re-adding later
1352
+ if isinstance(part_dict, dict):
1353
+ part_id = part_dict.get("id") or part_dict.get("partId")
1354
+ text = part_dict.get("text")
1355
+ if (
1356
+ isinstance(part_id, str)
1357
+ and part_id
1358
+ and isinstance(text, str)
1359
+ ):
1360
+ part_lengths[part_id] = len(text)
1361
+ elif isinstance(text, str):
1362
+ last_full_text = text
1363
+ if part_handler and part_dict:
1364
+ await part_handler(
1365
+ "text", part_with_session or part_dict, delta_text
1366
+ )
1367
+ elif part_handler and part_dict and part_type:
1368
+ await part_handler(
1369
+ part_type, part_with_session or part_dict, delta_text
1370
+ )
1371
+ elif (
1372
+ isinstance(part_dict, dict)
1373
+ and part_type in (None, "text")
1374
+ and not part_ignored
1375
+ ):
1376
+ if not is_primary_session:
1377
+ continue
1378
+ text = part_dict.get("text")
1379
+ if isinstance(text, str) and text:
1380
+ part_id = part_dict.get("id") or part_dict.get("partId")
1381
+ if isinstance(part_id, str) and part_id:
1382
+ last_len = part_lengths.get(part_id, 0)
1383
+ if len(text) > last_len:
1384
+ _append_text_for_message(
1385
+ part_message_id, text[last_len:]
1386
+ )
1387
+ part_lengths[part_id] = len(text)
1388
+ else:
1389
+ if last_full_text and text.startswith(last_full_text):
1390
+ _append_text_for_message(
1391
+ part_message_id, text[len(last_full_text) :]
1392
+ )
1393
+ elif text != last_full_text:
1394
+ _append_text_for_message(part_message_id, text)
1395
+ last_full_text = text
1396
+ elif part_handler and part_dict and part_type:
1397
+ await part_handler(part_type, part_with_session or part_dict, None)
1398
+ if event.event in ("message.completed", "message.updated"):
1399
+ message_result = parse_message_response(payload)
1400
+ msg_id = None
1401
+ role = None
1402
+ if is_primary_session:
1403
+ msg_id, role = _register_message_role(payload)
1404
+ resolved_role = role
1405
+ if resolved_role is None and msg_id:
1406
+ resolved_role = message_roles.get(msg_id)
1407
+ if message_result.text:
1408
+ if resolved_role == "assistant" or resolved_role is None:
1409
+ fallback_message = (
1410
+ msg_id,
1411
+ resolved_role,
1412
+ message_result.text,
1413
+ )
1414
+ if resolved_role is None:
1415
+ log_event(
1416
+ logger,
1417
+ logging.DEBUG,
1418
+ "opencode.message.completed.role_missing",
1419
+ session_id=event_session_id,
1420
+ message_id=msg_id,
1421
+ )
1422
+ else:
1423
+ log_event(
1424
+ logger,
1425
+ logging.DEBUG,
1426
+ "opencode.message.completed.ignored",
1427
+ session_id=event_session_id,
1428
+ message_id=msg_id,
1429
+ role=resolved_role,
447
1430
  )
448
- elif text != last_full_text:
449
- _append_text_for_message(part_message_id, text)
450
- last_full_text = text
451
- elif part_handler and part_dict and part_type:
452
- await part_handler(part_type, part_dict, None)
453
- if event.event in ("message.completed", "message.updated"):
454
- message_result = parse_message_response(payload)
455
- msg_id, role = _register_message_role(payload)
456
- if message_result.text and not text_parts and role != "user":
457
- _append_text_for_message(msg_id, message_result.text)
458
- if message_result.error and not error:
459
- error = message_result.error
460
- if event.event == "session.idle":
461
- if not text_parts and pending_text:
1431
+ if message_result.error and not error:
1432
+ error = message_result.error
1433
+ if part_handler is not None and is_primary_session:
1434
+ usage = _extract_usage_payload(payload)
1435
+ if usage is not None:
1436
+ provider_id, model_id = _extract_model_ids(payload)
1437
+ if not provider_id or not model_id:
1438
+ provider_id, model_id = await _resolve_session_model_ids()
1439
+ total_tokens = _extract_total_tokens(usage)
1440
+ context_window = _extract_context_window(payload, usage)
1441
+ if context_window is None:
1442
+ context_window = (
1443
+ await _resolve_context_window_from_providers(
1444
+ provider_id, model_id
1445
+ )
1446
+ )
1447
+ usage_details = _extract_usage_details(usage)
1448
+ if (
1449
+ total_tokens != last_usage_total
1450
+ or context_window != last_context_window
1451
+ ):
1452
+ last_usage_total = total_tokens
1453
+ last_context_window = context_window
1454
+ usage_snapshot: dict[str, Any] = {}
1455
+ if provider_id:
1456
+ usage_snapshot["providerID"] = provider_id
1457
+ if model_id:
1458
+ usage_snapshot["modelID"] = model_id
1459
+ if total_tokens is not None:
1460
+ usage_snapshot["totalTokens"] = total_tokens
1461
+ if usage_details:
1462
+ usage_snapshot.update(usage_details)
1463
+ if context_window is not None:
1464
+ usage_snapshot["modelContextWindow"] = context_window
1465
+ if usage_snapshot:
1466
+ await part_handler("usage", usage_snapshot, None)
1467
+ if event.event == "session.idle" or (
1468
+ event.event == "session.status"
1469
+ and _status_is_idle(_extract_status_type(payload))
1470
+ ):
1471
+ if not is_primary_session:
1472
+ continue
1473
+ if not text_parts and pending_text:
1474
+ _flush_all_pending_text()
1475
+ break
1476
+ if event.event == "message.completed" and is_primary_session:
1477
+ last_primary_completion_at = time.monotonic()
1478
+ finally:
1479
+ await _close_stream(stream_iter)
1480
+
1481
+ if not text_parts and fallback_message is not None:
1482
+ msg_id, role, text = fallback_message
1483
+ resolved_role = role
1484
+ if resolved_role is None and msg_id:
1485
+ resolved_role = message_roles.get(msg_id)
1486
+ if resolved_role == "assistant":
1487
+ _append_text_for_message(msg_id, text)
1488
+ if pending_text:
462
1489
  _flush_all_pending_text()
463
- break
464
1490
 
465
1491
  return OpenCodeTurnOutput(text="".join(text_parts).strip(), error=error)
466
1492
 
@@ -470,22 +1496,65 @@ async def collect_opencode_output(
470
1496
  *,
471
1497
  session_id: str,
472
1498
  workspace_path: str,
1499
+ model_payload: Optional[dict[str, str]] = None,
1500
+ progress_session_ids: Optional[set[str]] = None,
473
1501
  permission_policy: str = PERMISSION_ALLOW,
474
1502
  permission_handler: Optional[PermissionHandler] = None,
1503
+ question_policy: str = "ignore",
1504
+ question_handler: Optional[QuestionHandler] = None,
475
1505
  should_stop: Optional[Callable[[], bool]] = None,
1506
+ ready_event: Optional[Any] = None,
476
1507
  part_handler: Optional[PartHandler] = None,
1508
+ stall_timeout_seconds: Optional[float] = _OPENCODE_STREAM_STALL_TIMEOUT_SECONDS,
477
1509
  ) -> OpenCodeTurnOutput:
478
1510
  async def _respond(request_id: str, reply: str) -> None:
479
1511
  await client.respond_permission(request_id=request_id, reply=reply)
480
1512
 
1513
+ async def _reply_question(request_id: str, answers: list[list[str]]) -> None:
1514
+ await client.reply_question(request_id, answers=answers)
1515
+
1516
+ async def _reject_question(request_id: str) -> None:
1517
+ await client.reject_question(request_id)
1518
+
1519
+ def _stream_factory() -> AsyncIterator[SSEEvent]:
1520
+ return cast(
1521
+ AsyncIterator[SSEEvent],
1522
+ client.stream_events(directory=workspace_path, ready_event=ready_event),
1523
+ )
1524
+
1525
+ async def _fetch_session() -> Any:
1526
+ statuses = await client.session_status(directory=workspace_path)
1527
+ if isinstance(statuses, dict):
1528
+ session_status = statuses.get(session_id)
1529
+ if session_status is None:
1530
+ return {"status": {"type": "idle"}}
1531
+ if isinstance(session_status, dict):
1532
+ return {"status": session_status}
1533
+ if isinstance(session_status, str):
1534
+ return {"status": session_status}
1535
+ return {"status": {}}
1536
+
1537
+ async def _fetch_providers() -> Any:
1538
+ return await client.providers(directory=workspace_path)
1539
+
481
1540
  return await collect_opencode_output_from_events(
482
- client.stream_events(directory=workspace_path),
1541
+ None,
483
1542
  session_id=session_id,
1543
+ progress_session_ids=progress_session_ids,
484
1544
  permission_policy=permission_policy,
485
1545
  permission_handler=permission_handler,
1546
+ question_policy=question_policy,
1547
+ question_handler=question_handler,
486
1548
  should_stop=should_stop,
487
1549
  respond_permission=_respond,
1550
+ reply_question=_reply_question,
1551
+ reject_question=_reject_question,
488
1552
  part_handler=part_handler,
1553
+ event_stream_factory=_stream_factory,
1554
+ model_payload=model_payload,
1555
+ session_fetcher=_fetch_session,
1556
+ provider_fetcher=_fetch_providers,
1557
+ stall_timeout_seconds=stall_timeout_seconds,
489
1558
  )
490
1559
 
491
1560
 
@@ -505,5 +1574,6 @@ __all__ = [
505
1574
  "opencode_missing_env",
506
1575
  "parse_message_response",
507
1576
  "PartHandler",
1577
+ "QuestionHandler",
508
1578
  "split_model_id",
509
1579
  ]