codex-autorunner 0.1.2__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 (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -4,6 +4,7 @@ import asyncio
4
4
  import dataclasses
5
5
  import json
6
6
  import logging
7
+ import re
7
8
  from typing import Any, AsyncIterator, Iterable, Optional
8
9
 
9
10
  import httpx
@@ -48,7 +49,18 @@ def _normalize_sse_event(event: SSEEvent) -> SSEEvent:
48
49
  payload_obj = None
49
50
 
50
51
  if isinstance(payload_obj, dict) and isinstance(payload_obj.get("payload"), dict):
51
- payload_obj = payload_obj["payload"]
52
+ outer = payload_obj
53
+ inner = dict(outer.get("payload") or {})
54
+ if "type" not in inner and isinstance(outer.get("type"), str):
55
+ inner["type"] = outer["type"]
56
+ for key in ("sessionID", "sessionId", "session_id"):
57
+ if key in outer and key not in inner:
58
+ inner[key] = outer[key]
59
+ if "session" in outer and "session" not in inner:
60
+ inner["session"] = outer["session"]
61
+ if "properties" in outer and "properties" not in inner:
62
+ inner["properties"] = outer["properties"]
63
+ payload_obj = inner
52
64
 
53
65
  if isinstance(payload_obj, dict):
54
66
  payload_type = payload_obj.get("type")
@@ -505,49 +517,70 @@ class OpenCodeClient:
505
517
 
506
518
  async def fetch_openapi_spec(self) -> dict[str, Any]:
507
519
  """Fetch OpenAPI spec from /doc endpoint for capability negotiation."""
508
- async with self._client.stream("GET", "/doc") as response:
509
- response.raise_for_status()
510
- content = response.content
511
- try:
512
- spec = json.loads(content) if content else {}
513
- log_event(
514
- self._logger,
515
- logging.INFO,
516
- "opencode.openapi.fetched",
517
- paths=len(spec.get("paths", {})) if isinstance(spec, dict) else 0,
518
- has_components=(
519
- "components" in spec if isinstance(spec, dict) else False
520
- ),
521
- )
522
- return spec
523
- except Exception as exc:
524
- log_event(
525
- self._logger,
526
- logging.WARNING,
527
- "opencode.openapi.parse_failed",
528
- exc=exc,
529
- )
530
- raise OpenCodeProtocolError(
531
- f"Failed to parse OpenAPI spec: {exc}",
532
- status_code=response.status_code,
533
- content_type=(
534
- response.headers.get("content-type") if response else None
535
- ),
536
- ) from exc
520
+ response = await self._client.get("/doc")
521
+ response.raise_for_status()
522
+ content = response.content
523
+ try:
524
+ spec = json.loads(content) if content else {}
525
+ log_event(
526
+ self._logger,
527
+ logging.INFO,
528
+ "opencode.openapi.fetched",
529
+ paths=len(spec.get("paths", {})) if isinstance(spec, dict) else 0,
530
+ has_components=(
531
+ "components" in spec if isinstance(spec, dict) else False
532
+ ),
533
+ )
534
+ return spec
535
+ except Exception as exc:
536
+ log_event(
537
+ self._logger,
538
+ logging.WARNING,
539
+ "opencode.openapi.parse_failed",
540
+ exc=exc,
541
+ )
542
+ raise OpenCodeProtocolError(
543
+ f"Failed to parse OpenAPI spec: {exc}",
544
+ status_code=response.status_code,
545
+ content_type=(
546
+ response.headers.get("content-type") if response else None
547
+ ),
548
+ ) from exc
537
549
 
538
550
  def has_endpoint(
539
551
  self, openapi_spec: dict[str, Any], method: str, path: str
540
552
  ) -> bool:
541
- """Check if endpoint is available in OpenAPI spec."""
553
+ """Check if endpoint is available in OpenAPI spec.
554
+
555
+ The OpenAPI spec sometimes uses different template parameter names (e.g.,
556
+ `{sessionID}` vs `{session_id}`). We normalize templates before matching so
557
+ capability detection does not depend on placeholder spelling.
558
+ """
542
559
  if not isinstance(openapi_spec, dict):
543
560
  return False
544
561
  paths = openapi_spec.get("paths", {})
545
562
  if not isinstance(paths, dict):
546
563
  return False
547
- path_info = paths.get(path)
548
- if not isinstance(path_info, dict):
549
- return False
550
- return method in path_info
564
+
565
+ target = _normalize_template_path(path)
566
+ method = method.lower()
567
+
568
+ for candidate_path, info in paths.items():
569
+ if not isinstance(info, dict):
570
+ continue
571
+ if _normalize_template_path(candidate_path) != target:
572
+ continue
573
+ if method in info:
574
+ return True
575
+ return False
576
+
577
+
578
+ def _normalize_template_path(path: str) -> str:
579
+ """Collapse template placeholders to a canonical form.
580
+
581
+ Example: `/session/{sessionID}/prompt_async` -> `/session/{}/prompt_async`
582
+ """
583
+ return re.sub(r"{[^/]+}", "{}", path)
551
584
 
552
585
 
553
586
  __all__ = ["OpenCodeClient", "OpenCodeProtocolError", "OpenCodeApiProfile"]
@@ -66,11 +66,6 @@ class OpenCodeEventFormatter:
66
66
  for line in complete_lines:
67
67
  if line.strip():
68
68
  lines.append(f"**{line.strip()}**")
69
-
70
- remaining = coalescer.get_buffer()
71
- if remaining and remaining.strip():
72
- lines.append(f"**{remaining.strip()}**")
73
- coalescer.clear()
74
69
  return lines
75
70
 
76
71
  def _format_tool_part(self, part: dict[str, Any]) -> list[str]:
@@ -120,6 +115,27 @@ class OpenCodeEventFormatter:
120
115
  lines.append("exec")
121
116
  lines.append(f"tool: {tool_name}")
122
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
+
123
139
  return lines
124
140
 
125
141
  def _format_patch_part(self, part: dict[str, Any]) -> list[str]:
@@ -112,6 +112,7 @@ async def run_opencode_prompt(
112
112
  client,
113
113
  session_id=session_id,
114
114
  workspace_path=config.workspace_root,
115
+ model_payload=model_payload,
115
116
  permission_policy=permission_policy,
116
117
  should_stop=should_stop,
117
118
  ready_event=ready_event,
@@ -15,6 +15,7 @@ from typing import (
15
15
  Callable,
16
16
  MutableMapping,
17
17
  Optional,
18
+ cast,
18
19
  )
19
20
 
20
21
  import httpx
@@ -127,17 +128,19 @@ def extract_session_id(
127
128
  return value
128
129
  properties = payload.get("properties")
129
130
  if isinstance(properties, dict):
130
- value = properties.get("sessionID")
131
- if isinstance(value, str) and value:
132
- return value
133
- part = properties.get("part")
134
- if isinstance(part, dict):
135
- value = part.get("sessionID")
131
+ for key in ("sessionID", "sessionId", "session_id"):
132
+ value = properties.get(key)
136
133
  if isinstance(value, str) and value:
137
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
138
141
  session = payload.get("session")
139
142
  if isinstance(session, dict):
140
- return extract_session_id(session, allow_fallback_id=allow_fallback_id)
143
+ return extract_session_id(session, allow_fallback_id=True)
141
144
  return None
142
145
 
143
146
 
@@ -676,7 +679,7 @@ async def opencode_missing_env(
676
679
  providers = [entry for entry in payload if isinstance(entry, dict)]
677
680
  for provider in providers:
678
681
  pid = provider.get("id") or provider.get("providerID")
679
- if pid != provider_id:
682
+ if not pid or pid != provider_id:
680
683
  continue
681
684
  if _provider_has_auth(pid, workspace_root):
682
685
  return []
@@ -732,6 +735,7 @@ async def collect_opencode_output_from_events(
732
735
  events: Optional[AsyncIterator[SSEEvent]] = None,
733
736
  *,
734
737
  session_id: str,
738
+ model_payload: Optional[dict[str, str]] = None,
735
739
  progress_session_ids: Optional[set[str]] = None,
736
740
  permission_policy: str = PERMISSION_ALLOW,
737
741
  permission_handler: Optional[PermissionHandler] = None,
@@ -759,13 +763,16 @@ async def collect_opencode_output_from_events(
759
763
  last_usage_total: Optional[int] = None
760
764
  last_context_window: Optional[int] = None
761
765
  part_types: dict[str, str] = {}
762
- seen_question_request_ids: set[tuple[str, str]] = set()
766
+ seen_question_request_ids: set[tuple[Optional[str], str]] = set()
763
767
  logged_permission_errors: set[str] = set()
764
768
  normalized_question_policy = _normalize_question_policy(question_policy)
765
769
  logger = logging.getLogger(__name__)
766
770
  providers_cache: Optional[list[dict[str, Any]]] = None
767
771
  context_window_cache: dict[str, Optional[int]] = {}
768
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
+ )
769
776
 
770
777
  def _message_id_from_info(info: Any) -> Optional[str]:
771
778
  if not isinstance(info, dict):
@@ -843,15 +850,19 @@ async def collect_opencode_output_from_events(
843
850
  nonlocal session_model_ids
844
851
  if session_model_ids is not None:
845
852
  return session_model_ids
846
- if session_fetcher is None:
847
- session_model_ids = (None, None)
848
- return session_model_ids
849
- try:
850
- payload = await session_fetcher()
851
- except Exception:
852
- session_model_ids = (None, None)
853
- return session_model_ids
854
- session_model_ids = _extract_model_ids(payload)
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)
855
866
  return session_model_ids
856
867
 
857
868
  async def _resolve_context_window_from_providers(
@@ -946,7 +957,7 @@ async def collect_opencode_output_from_events(
946
957
  await aclose()
947
958
 
948
959
  stream_iter = _new_stream().__aiter__()
949
- last_event_at = time.monotonic()
960
+ last_relevant_event_at = time.monotonic()
950
961
  last_primary_completion_at: Optional[float] = None
951
962
  reconnect_attempts = 0
952
963
  can_reconnect = (
@@ -981,6 +992,7 @@ async def collect_opencode_output_from_events(
981
992
  session_id=session_id,
982
993
  exc=exc,
983
994
  )
995
+ idle_seconds = now - last_relevant_event_at
984
996
  if _status_is_idle(status_type):
985
997
  log_event(
986
998
  logger,
@@ -988,7 +1000,7 @@ async def collect_opencode_output_from_events(
988
1000
  "opencode.stream.stalled.session_idle",
989
1001
  session_id=session_id,
990
1002
  status_type=status_type,
991
- idle_seconds=now - last_event_at,
1003
+ idle_seconds=idle_seconds,
992
1004
  )
993
1005
  if not text_parts and pending_text:
994
1006
  _flush_all_pending_text()
@@ -1000,7 +1012,7 @@ async def collect_opencode_output_from_events(
1000
1012
  "opencode.stream.stalled.after_completion",
1001
1013
  session_id=session_id,
1002
1014
  status_type=status_type,
1003
- idle_seconds=now - last_event_at,
1015
+ idle_seconds=idle_seconds,
1004
1016
  )
1005
1017
  if not can_reconnect:
1006
1018
  break
@@ -1015,7 +1027,7 @@ async def collect_opencode_output_from_events(
1015
1027
  logging.WARNING,
1016
1028
  "opencode.stream.stalled.reconnecting",
1017
1029
  session_id=session_id,
1018
- idle_seconds=now - last_event_at,
1030
+ idle_seconds=idle_seconds,
1019
1031
  backoff_seconds=backoff,
1020
1032
  status_type=status_type,
1021
1033
  attempts=reconnect_attempts,
@@ -1023,21 +1035,86 @@ async def collect_opencode_output_from_events(
1023
1035
  await _close_stream(stream_iter)
1024
1036
  await asyncio.sleep(backoff)
1025
1037
  stream_iter = _new_stream().__aiter__()
1038
+ last_relevant_event_at = now
1026
1039
  continue
1027
- last_event_at = time.monotonic()
1040
+ now = time.monotonic()
1028
1041
  raw = event.data or ""
1029
1042
  try:
1030
1043
  payload = json.loads(raw) if raw else {}
1031
1044
  except json.JSONDecodeError:
1032
1045
  payload = {}
1033
1046
  event_session_id = extract_session_id(payload)
1034
- if not event_session_id:
1035
- continue
1036
- if progress_session_ids is None:
1037
- if event_session_id != session_id:
1038
- continue
1039
- elif event_session_id not in progress_session_ids:
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
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__()
1040
1115
  continue
1116
+ last_relevant_event_at = now
1117
+ reconnect_attempts = 0
1041
1118
  is_primary_session = event_session_id == session_id
1042
1119
  if event.event == "question.asked":
1043
1120
  request_id, props = _extract_question_request(payload)
@@ -1419,6 +1496,7 @@ async def collect_opencode_output(
1419
1496
  *,
1420
1497
  session_id: str,
1421
1498
  workspace_path: str,
1499
+ model_payload: Optional[dict[str, str]] = None,
1422
1500
  progress_session_ids: Optional[set[str]] = None,
1423
1501
  permission_policy: str = PERMISSION_ALLOW,
1424
1502
  permission_handler: Optional[PermissionHandler] = None,
@@ -1427,6 +1505,7 @@ async def collect_opencode_output(
1427
1505
  should_stop: Optional[Callable[[], bool]] = None,
1428
1506
  ready_event: Optional[Any] = None,
1429
1507
  part_handler: Optional[PartHandler] = None,
1508
+ stall_timeout_seconds: Optional[float] = _OPENCODE_STREAM_STALL_TIMEOUT_SECONDS,
1430
1509
  ) -> OpenCodeTurnOutput:
1431
1510
  async def _respond(request_id: str, reply: str) -> None:
1432
1511
  await client.respond_permission(request_id=request_id, reply=reply)
@@ -1438,14 +1517,21 @@ async def collect_opencode_output(
1438
1517
  await client.reject_question(request_id)
1439
1518
 
1440
1519
  def _stream_factory() -> AsyncIterator[SSEEvent]:
1441
- return client.stream_events(directory=workspace_path, ready_event=ready_event)
1520
+ return cast(
1521
+ AsyncIterator[SSEEvent],
1522
+ client.stream_events(directory=workspace_path, ready_event=ready_event),
1523
+ )
1442
1524
 
1443
1525
  async def _fetch_session() -> Any:
1444
1526
  statuses = await client.session_status(directory=workspace_path)
1445
1527
  if isinstance(statuses, dict):
1446
1528
  session_status = statuses.get(session_id)
1529
+ if session_status is None:
1530
+ return {"status": {"type": "idle"}}
1447
1531
  if isinstance(session_status, dict):
1448
1532
  return {"status": session_status}
1533
+ if isinstance(session_status, str):
1534
+ return {"status": session_status}
1449
1535
  return {"status": {}}
1450
1536
 
1451
1537
  async def _fetch_providers() -> Any:
@@ -1465,8 +1551,10 @@ async def collect_opencode_output(
1465
1551
  reject_question=_reject_question,
1466
1552
  part_handler=part_handler,
1467
1553
  event_stream_factory=_stream_factory,
1554
+ model_payload=model_payload,
1468
1555
  session_fetcher=_fetch_session,
1469
1556
  provider_fetcher=_fetch_providers,
1557
+ stall_timeout_seconds=stall_timeout_seconds,
1470
1558
  )
1471
1559
 
1472
1560
 
@@ -11,6 +11,7 @@ from typing import Any, Mapping, Optional, Sequence
11
11
  import httpx
12
12
 
13
13
  from ...core.logging_utils import log_event
14
+ from ...core.supervisor_utils import evict_lru_handle_locked, pop_idle_handles_locked
14
15
  from ...core.utils import infer_home_from_workspace, subprocess_env
15
16
  from ...workspace import canonical_workspace_root, workspace_id_for_path
16
17
  from .client import OpenCodeClient
@@ -53,20 +54,28 @@ class OpenCodeSupervisor:
53
54
  base_env: Optional[Mapping[str, str]] = None,
54
55
  base_url: Optional[str] = None,
55
56
  subagent_models: Optional[Mapping[str, str]] = None,
57
+ session_stall_timeout_seconds: Optional[float] = None,
56
58
  ) -> None:
57
59
  self._command = [str(arg) for arg in command]
58
60
  self._logger = logger or logging.getLogger(__name__)
59
61
  self._request_timeout = request_timeout
60
62
  self._max_handles = max_handles
61
63
  self._idle_ttl_seconds = idle_ttl_seconds
64
+ self._session_stall_timeout_seconds = session_stall_timeout_seconds
62
65
  if password and not username:
63
66
  username = "opencode"
64
- self._auth = (username, password) if password else None
67
+ self._auth: Optional[tuple[str, str]] = (
68
+ (username, password) if password and username else None
69
+ )
65
70
  self._base_env = base_env
66
71
  self._base_url = base_url
67
72
  self._subagent_models = subagent_models or {}
68
73
  self._handles: dict[str, OpenCodeHandle] = {}
69
- self._lock = asyncio.Lock()
74
+ self._lock: Optional[asyncio.Lock] = None
75
+
76
+ @property
77
+ def session_stall_timeout_seconds(self) -> Optional[float]:
78
+ return self._session_stall_timeout_seconds
70
79
 
71
80
  async def get_client(self, workspace_root: Path) -> OpenCodeClient:
72
81
  canonical_root = canonical_workspace_root(workspace_root)
@@ -79,7 +88,7 @@ class OpenCodeSupervisor:
79
88
  return handle.client
80
89
 
81
90
  async def close_all(self) -> None:
82
- async with self._lock:
91
+ async with self._get_lock():
83
92
  handles = list(self._handles.values())
84
93
  self._handles = {}
85
94
  for handle in handles:
@@ -98,7 +107,7 @@ class OpenCodeSupervisor:
98
107
  async def mark_turn_started(self, workspace_root: Path) -> None:
99
108
  canonical_root = canonical_workspace_root(workspace_root)
100
109
  workspace_id = workspace_id_for_path(canonical_root)
101
- async with self._lock:
110
+ async with self._get_lock():
102
111
  handle = self._handles.get(workspace_id)
103
112
  if handle is None:
104
113
  return
@@ -108,7 +117,7 @@ class OpenCodeSupervisor:
108
117
  async def mark_turn_finished(self, workspace_root: Path) -> None:
109
118
  canonical_root = canonical_workspace_root(workspace_root)
110
119
  workspace_id = workspace_id_for_path(canonical_root)
111
- async with self._lock:
120
+ async with self._get_lock():
112
121
  handle = self._handles.get(workspace_id)
113
122
  if handle is None:
114
123
  return
@@ -187,7 +196,7 @@ class OpenCodeSupervisor:
187
196
  ) -> OpenCodeHandle:
188
197
  handles_to_close: list[OpenCodeHandle] = []
189
198
  evicted_id: Optional[str] = None
190
- async with self._lock:
199
+ async with self._get_lock():
191
200
  existing = self._handles.get(workspace_id)
192
201
  if existing is not None:
193
202
  existing.last_used_at = time.monotonic()
@@ -287,7 +296,7 @@ class OpenCodeSupervisor:
287
296
  logging.WARNING,
288
297
  "opencode.openapi.fetch_failed",
289
298
  base_url=base_url,
290
- exc=str(exc),
299
+ exc=exc,
291
300
  )
292
301
  handle.openapi_spec = {}
293
302
  handle.started = True
@@ -356,7 +365,7 @@ class OpenCodeSupervisor:
356
365
  logging.WARNING,
357
366
  "opencode.openapi.fetch_failed",
358
367
  base_url=base_url,
359
- exc=str(exc),
368
+ exc=exc,
360
369
  )
361
370
  handle.openapi_spec = {}
362
371
  self._start_stdout_drain(handle)
@@ -468,53 +477,32 @@ class OpenCodeSupervisor:
468
477
  return match.group(1)
469
478
 
470
479
  async def _pop_idle_handles(self) -> list[OpenCodeHandle]:
471
- async with self._lock:
480
+ async with self._get_lock():
472
481
  return self._pop_idle_handles_locked()
473
482
 
483
+ def _get_lock(self) -> asyncio.Lock:
484
+ if self._lock is None:
485
+ self._lock = asyncio.Lock()
486
+ return self._lock
487
+
474
488
  def _pop_idle_handles_locked(self) -> list[OpenCodeHandle]:
475
- if not self._idle_ttl_seconds or self._idle_ttl_seconds <= 0:
476
- return []
477
- cutoff = time.monotonic() - self._idle_ttl_seconds
478
- stale: list[OpenCodeHandle] = []
479
- for handle in list(self._handles.values()):
480
- if handle.active_turns:
481
- log_event(
482
- self._logger,
483
- logging.INFO,
484
- "opencode.handle.prune.skipped",
485
- reason="active_turns",
486
- workspace_id=handle.workspace_id,
487
- workspace_root=str(handle.workspace_root),
488
- active_turns=handle.active_turns,
489
- )
490
- continue
491
- if handle.last_used_at and handle.last_used_at < cutoff:
492
- self._handles.pop(handle.workspace_id, None)
493
- stale.append(handle)
494
- return stale
489
+ return pop_idle_handles_locked(
490
+ self._handles,
491
+ self._idle_ttl_seconds,
492
+ self._logger,
493
+ "opencode",
494
+ last_used_at_getter=lambda h: h.last_used_at,
495
+ should_skip_prune=lambda h: h.active_turns > 0,
496
+ )
495
497
 
496
498
  def _evict_lru_handle_locked(self) -> Optional[OpenCodeHandle]:
497
- if not self._max_handles or self._max_handles <= 0:
498
- return None
499
- if len(self._handles) < self._max_handles:
500
- return None
501
- lru_handle = min(
502
- self._handles.values(),
503
- key=lambda handle: handle.last_used_at or 0.0,
504
- )
505
- log_event(
499
+ return evict_lru_handle_locked(
500
+ self._handles,
501
+ self._max_handles,
506
502
  self._logger,
507
- logging.INFO,
508
- "opencode.handle.evicted",
509
- reason="max_handles",
510
- workspace_id=lru_handle.workspace_id,
511
- workspace_root=str(lru_handle.workspace_root),
512
- max_handles=self._max_handles,
513
- handle_count=len(self._handles),
514
- last_used_at=lru_handle.last_used_at,
503
+ "opencode",
504
+ last_used_at_getter=lambda h: h.last_used_at or 0.0,
515
505
  )
516
- self._handles.pop(lru_handle.workspace_id, None)
517
- return lru_handle
518
506
 
519
507
 
520
508
  __all__ = ["OpenCodeHandle", "OpenCodeSupervisor", "OpenCodeSupervisorError"]