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
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == "__main__":
4
+ app()
@@ -1 +1,21 @@
1
1
  """Agent harness abstractions."""
2
+
3
+ from .registry import (
4
+ AgentCapability,
5
+ AgentDescriptor,
6
+ get_agent_descriptor,
7
+ get_available_agents,
8
+ get_registered_agents,
9
+ has_capability,
10
+ validate_agent_id,
11
+ )
12
+
13
+ __all__ = [
14
+ "AgentCapability",
15
+ "AgentDescriptor",
16
+ "get_registered_agents",
17
+ "get_available_agents",
18
+ "get_agent_descriptor",
19
+ "validate_agent_id",
20
+ "has_capability",
21
+ ]
@@ -3,11 +3,11 @@ from __future__ import annotations
3
3
  from pathlib import Path
4
4
  from typing import Any, AsyncIterator, Optional, Protocol
5
5
 
6
- from .types import ConversationRef, ModelCatalog, TurnRef
6
+ from .types import AgentId, ConversationRef, ModelCatalog, TurnRef
7
7
 
8
8
 
9
9
  class AgentHarness(Protocol):
10
- agent_id: str
10
+ agent_id: AgentId
11
11
  display_name: str
12
12
 
13
13
  async def ensure_ready(self, workspace_root: Path) -> None: ...
@@ -73,7 +73,7 @@ def _coerce_reasoning_efforts(entry: dict[str, Any]) -> list[str]:
73
73
 
74
74
 
75
75
  class CodexHarness(AgentHarness):
76
- agent_id: AgentId = "codex"
76
+ agent_id: AgentId = AgentId("codex")
77
77
  display_name = "Codex"
78
78
 
79
79
  def __init__(
@@ -3,12 +3,16 @@
3
3
  from .client import OpenCodeClient
4
4
  from .events import SSEEvent, parse_sse_lines
5
5
  from .harness import OpenCodeHarness
6
+ from .run_prompt import OpenCodeRunConfig, OpenCodeRunResult, run_opencode_prompt
6
7
  from .supervisor import OpenCodeSupervisor
7
8
 
8
9
  __all__ = [
9
10
  "OpenCodeClient",
10
11
  "OpenCodeHarness",
12
+ "OpenCodeRunConfig",
13
+ "OpenCodeRunResult",
11
14
  "OpenCodeSupervisor",
12
15
  "SSEEvent",
13
16
  "parse_sse_lines",
17
+ "run_opencode_prompt",
14
18
  ]
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ async def ensure_agent_config(
12
+ workspace_root: Path,
13
+ agent_id: str,
14
+ model: Optional[str],
15
+ title: Optional[str] = None,
16
+ description: Optional[str] = None,
17
+ ) -> None:
18
+ """Ensure .opencode/agent/<agent_id>.md exists with frontmatter config.
19
+
20
+ Args:
21
+ workspace_root: Path to the workspace root
22
+ agent_id: Agent ID (e.g., "subagent")
23
+ model: Model ID in format "providerID/modelID" (e.g., "zai-coding-plan/glm-4.7-flashx")
24
+ title: Optional title for the agent
25
+ description: Optional description for the agent
26
+ """
27
+ if model is None:
28
+ logger.debug(f"Skipping agent config for {agent_id}: no model configured")
29
+ return
30
+
31
+ agent_dir = workspace_root / ".opencode" / "agent"
32
+ agent_file = agent_dir / f"{agent_id}.md"
33
+
34
+ # Check if file already exists and has the correct model
35
+ if agent_file.exists():
36
+ existing_content = agent_file.read_text(encoding="utf-8")
37
+ existing_model = _extract_model_from_frontmatter(existing_content)
38
+ if existing_model == model:
39
+ logger.debug(f"Agent config already exists for {agent_id}: {agent_file}")
40
+ return
41
+
42
+ # Create agent directory if needed
43
+ await asyncio.to_thread(agent_dir.mkdir, parents=True, exist_ok=True)
44
+
45
+ # Build agent markdown with frontmatter
46
+ content = _build_agent_md(
47
+ agent_id=agent_id,
48
+ model=model,
49
+ title=title or agent_id,
50
+ description=description or f"Subagent for {agent_id} tasks",
51
+ )
52
+
53
+ # Write atomically
54
+ await asyncio.to_thread(agent_file.write_text, content, encoding="utf-8")
55
+ logger.info(f"Created agent config: {agent_file} with model {model}")
56
+
57
+
58
+ def _build_agent_md(
59
+ agent_id: str,
60
+ model: str,
61
+ title: str,
62
+ description: str,
63
+ ) -> str:
64
+ """Generate markdown with YAML frontmatter.
65
+
66
+ Frontmatter format per OpenCode config schema:
67
+ ---
68
+ agent: <agent_id>
69
+ title: "<title>"
70
+ description: "<description>"
71
+ model: <providerID>/<modelID>
72
+ ---
73
+
74
+ <Optional agent instructions go here>
75
+ """
76
+ return f"""---
77
+ agent: {agent_id}
78
+ title: "{title}"
79
+ description: "{description}"
80
+ model: {model}
81
+ ---
82
+ """
83
+
84
+
85
+ def _extract_model_from_frontmatter(content: str) -> Optional[str]:
86
+ """Extract model value from YAML frontmatter.
87
+
88
+ Returns None if frontmatter or model field is not found.
89
+ """
90
+ lines = content.splitlines()
91
+ if not lines or not lines[0].startswith("---"):
92
+ return None
93
+
94
+ for _i, line in enumerate(lines[1:], start=1):
95
+ if line.startswith("---"):
96
+ break
97
+ if line.startswith("model:"):
98
+ model = line.split(":", 1)[1].strip()
99
+ return model if model else None
100
+
101
+ return None
102
+
103
+
104
+ __all__ = ["ensure_agent_config"]
@@ -1,8 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
4
+ import dataclasses
3
5
  import json
4
6
  import logging
5
- from typing import Any, AsyncIterator, Optional
7
+ import re
8
+ from typing import Any, AsyncIterator, Iterable, Optional
6
9
 
7
10
  import httpx
8
11
 
@@ -12,6 +15,15 @@ from .events import SSEEvent, parse_sse_lines
12
15
  _MAX_INVALID_JSON_PREVIEW_BYTES = 512
13
16
 
14
17
 
18
+ @dataclasses.dataclass
19
+ class OpenCodeApiProfile:
20
+ """Detected OpenCode API capabilities from OpenAPI spec."""
21
+
22
+ supports_prompt_async: bool = True
23
+ supports_global_endpoints: bool = True
24
+ spec_fetched: bool = False
25
+
26
+
15
27
  class OpenCodeProtocolError(Exception):
16
28
  def __init__(
17
29
  self,
@@ -27,6 +39,43 @@ class OpenCodeProtocolError(Exception):
27
39
  self.body_preview = body_preview
28
40
 
29
41
 
42
+ def _normalize_sse_event(event: SSEEvent) -> SSEEvent:
43
+ event_type = event.event
44
+ raw_data = event.data or ""
45
+ payload_obj: Optional[dict[str, Any]] = None
46
+ try:
47
+ payload_obj = json.loads(raw_data) if raw_data else None
48
+ except (json.JSONDecodeError, TypeError):
49
+ payload_obj = None
50
+
51
+ if isinstance(payload_obj, dict) and isinstance(payload_obj.get("payload"), dict):
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
64
+
65
+ if isinstance(payload_obj, dict):
66
+ payload_type = payload_obj.get("type")
67
+ if isinstance(payload_type, str) and payload_type:
68
+ event_type = payload_type
69
+ raw_data = json.dumps(payload_obj)
70
+
71
+ return SSEEvent(
72
+ event=event_type,
73
+ data=raw_data,
74
+ id=event.id,
75
+ retry=event.retry,
76
+ )
77
+
78
+
30
79
  class OpenCodeClient:
31
80
  def __init__(
32
81
  self,
@@ -42,10 +91,62 @@ class OpenCodeClient:
42
91
  timeout=timeout,
43
92
  )
44
93
  self._logger = logger or logging.getLogger(__name__)
94
+ self._api_profile: Optional[OpenCodeApiProfile] = None
95
+ self._api_profile_lock = asyncio.Lock()
45
96
 
46
97
  async def close(self) -> None:
47
98
  await self._client.aclose()
48
99
 
100
+ async def detect_api_shape(self) -> OpenCodeApiProfile:
101
+ """Detect OpenCode API capabilities by fetching and parsing OpenAPI spec.
102
+ Results are cached for the lifetime of the client instance.
103
+ Thread-safe: multiple concurrent calls will wait for first detection to complete.
104
+ """
105
+ async with self._api_profile_lock:
106
+ if self._api_profile is not None:
107
+ return self._api_profile
108
+
109
+ profile = OpenCodeApiProfile()
110
+ try:
111
+ spec = await self.fetch_openapi_spec()
112
+ profile.spec_fetched = True
113
+
114
+ if isinstance(spec, dict):
115
+ # Check if /session/{id}/prompt_async exists
116
+ profile.supports_prompt_async = self.has_endpoint(
117
+ spec, "post", "/session/{session_id}/prompt_async"
118
+ )
119
+
120
+ # Check if /global/* endpoints exist
121
+ profile.supports_global_endpoints = self.has_endpoint(
122
+ spec, "get", "/global/health"
123
+ ) or self.has_endpoint(spec, "get", "/global/event")
124
+
125
+ log_event(
126
+ self._logger,
127
+ logging.INFO,
128
+ "opencode.api_shape_detected",
129
+ supports_prompt_async=profile.supports_prompt_async,
130
+ supports_global_endpoints=profile.supports_global_endpoints,
131
+ )
132
+ except Exception as exc:
133
+ self._logger.warning(
134
+ "Failed to detect API shape, assuming modern OpenCode: %s", exc
135
+ )
136
+ # Default to assuming modern OpenCode with all features
137
+ profile.supports_prompt_async = True
138
+ profile.supports_global_endpoints = True
139
+
140
+ self._api_profile = profile
141
+ return profile
142
+
143
+ def _get_api_profile(self) -> OpenCodeApiProfile:
144
+ """Get API profile, detecting if needed. Synchronous for use in sync methods."""
145
+ if self._api_profile is None:
146
+ # Return default profile if not yet detected
147
+ return OpenCodeApiProfile()
148
+ return self._api_profile
149
+
49
150
  def _dir_params(self, directory: Optional[str]) -> dict[str, str]:
50
151
  return {"directory": directory} if directory else {}
51
152
 
@@ -157,6 +258,14 @@ class OpenCodeClient:
157
258
  async def get_session(self, session_id: str) -> Any:
158
259
  return await self._request("GET", f"/session/{session_id}", expect_json=True)
159
260
 
261
+ async def session_status(self, *, directory: Optional[str] = None) -> Any:
262
+ return await self._request(
263
+ "GET",
264
+ "/session/status",
265
+ params=self._dir_params(directory),
266
+ expect_json=True,
267
+ )
268
+
160
269
  async def send_message(
161
270
  self,
162
271
  session_id: str,
@@ -200,22 +309,57 @@ class OpenCodeClient:
200
309
  payload["model"] = model
201
310
  if variant:
202
311
  payload["variant"] = variant
203
- try:
312
+
313
+ profile = await self.detect_api_shape()
314
+ if profile.supports_prompt_async:
315
+ return await self._request(
316
+ "POST",
317
+ f"/session/{session_id}/prompt_async",
318
+ json_body=payload,
319
+ expect_json=False,
320
+ )
321
+ else:
322
+ return await self._request(
323
+ "POST",
324
+ f"/session/{session_id}/message",
325
+ json_body=payload,
326
+ expect_json=True,
327
+ )
328
+
329
+ async def prompt_async(
330
+ self,
331
+ session_id: str,
332
+ *,
333
+ message: str,
334
+ agent: Optional[str] = None,
335
+ model: Optional[dict[str, str]] = None,
336
+ variant: Optional[str] = None,
337
+ ) -> Any:
338
+ payload: dict[str, Any] = {
339
+ "parts": [{"type": "text", "text": message}],
340
+ }
341
+ if agent:
342
+ payload["agent"] = agent
343
+ if model:
344
+ payload["model"] = model
345
+ if variant:
346
+ payload["variant"] = variant
347
+
348
+ profile = await self.detect_api_shape()
349
+ if profile.supports_prompt_async:
350
+ return await self._request(
351
+ "POST",
352
+ f"/session/{session_id}/prompt_async",
353
+ json_body=payload,
354
+ expect_json=False,
355
+ )
356
+ else:
204
357
  return await self._request(
205
358
  "POST",
206
359
  f"/session/{session_id}/message",
207
360
  json_body=payload,
208
361
  expect_json=True,
209
362
  )
210
- except httpx.HTTPStatusError as exc:
211
- if exc.response.status_code in (404, 405):
212
- return await self._request(
213
- "POST",
214
- f"/session/{session_id}/prompt_async",
215
- json_body=payload,
216
- expect_json=False,
217
- )
218
- raise
219
363
 
220
364
  async def send_command(
221
365
  self,
@@ -279,31 +423,164 @@ class OpenCodeClient:
279
423
  expect_json=False,
280
424
  )
281
425
 
426
+ async def list_questions(self) -> Any:
427
+ return await self._request("GET", "/question", expect_json=True)
428
+
429
+ async def reply_question(self, request_id: str, *, answers: list[list[str]]) -> Any:
430
+ payload: dict[str, Any] = {"answers": answers}
431
+ return await self._request(
432
+ "POST",
433
+ f"/question/{request_id}/reply",
434
+ json_body=payload,
435
+ expect_json=False,
436
+ )
437
+
438
+ async def reject_question(self, request_id: str) -> Any:
439
+ return await self._request(
440
+ "POST",
441
+ f"/question/{request_id}/reject",
442
+ expect_json=False,
443
+ )
444
+
282
445
  async def abort(self, session_id: str) -> Any:
283
446
  return await self._request(
284
447
  "POST", f"/session/{session_id}/abort", expect_json=False
285
448
  )
286
449
 
450
+ async def health(self) -> Any:
451
+ """Check OpenCode server health using /global/health or /health endpoint."""
452
+ profile = await self.detect_api_shape()
453
+ if profile.supports_global_endpoints:
454
+ return await self._request("GET", "/global/health", expect_json=True)
455
+ else:
456
+ return await self._request("GET", "/health", expect_json=True)
457
+
458
+ async def dispose(self, session_id: str) -> Any:
459
+ """Dispose of a session using /global/dispose/{id} or /session/{id}/dispose endpoint."""
460
+ profile = await self.detect_api_shape()
461
+ if profile.supports_global_endpoints:
462
+ return await self._request(
463
+ "POST", f"/global/dispose/{session_id}", expect_json=False
464
+ )
465
+ else:
466
+ return await self._request(
467
+ "POST", f"/session/{session_id}/dispose", expect_json=False
468
+ )
469
+
287
470
  async def stream_events(
288
- self, *, directory: Optional[str] = None
471
+ self,
472
+ *,
473
+ directory: Optional[str] = None,
474
+ ready_event: Optional[asyncio.Event] = None,
475
+ paths: Optional[Iterable[str]] = None,
289
476
  ) -> AsyncIterator[SSEEvent]:
290
477
  params = self._dir_params(directory)
291
- async with self._client.stream("GET", "/event", params=params) as response:
292
- response.raise_for_status()
293
- async for sse in parse_sse_lines(response.aiter_lines()):
294
- event_type = sse.event
295
- try:
296
- payload = json.loads(sse.data) if sse.data else None
297
- if isinstance(payload, dict) and "type" in payload:
298
- event_type = str(payload["type"])
299
- except (json.JSONDecodeError, TypeError):
300
- pass
301
- yield SSEEvent(
302
- event=event_type,
303
- data=sse.data,
304
- id=sse.id,
305
- retry=sse.retry,
478
+
479
+ if paths is not None:
480
+ event_paths = list(paths)
481
+ else:
482
+ profile = await self.detect_api_shape()
483
+ if profile.supports_global_endpoints:
484
+ event_paths = (
485
+ ["/event", "/global/event"]
486
+ if directory
487
+ else ["/global/event", "/event"]
306
488
  )
489
+ else:
490
+ event_paths = ["/event"]
491
+
492
+ last_error: Optional[BaseException] = None
493
+ for path in event_paths:
494
+ try:
495
+ async with self._client.stream(
496
+ "GET", path, params=params, timeout=None
497
+ ) as response:
498
+ response.raise_for_status()
499
+ if ready_event is not None:
500
+ ready_event.set()
501
+ async for sse in parse_sse_lines(response.aiter_lines()):
502
+ yield _normalize_sse_event(sse)
503
+ return
504
+ except httpx.HTTPStatusError as exc:
505
+ last_error = exc
506
+ status_code = exc.response.status_code
507
+ if status_code in (404, 405):
508
+ continue
509
+ raise
510
+ except Exception as exc:
511
+ last_error = exc
512
+ raise
513
+ if ready_event is not None and not ready_event.is_set():
514
+ ready_event.set()
515
+ if last_error is not None:
516
+ raise last_error
517
+
518
+ async def fetch_openapi_spec(self) -> dict[str, Any]:
519
+ """Fetch OpenAPI spec from /doc endpoint for capability negotiation."""
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
549
+
550
+ def has_endpoint(
551
+ self, openapi_spec: dict[str, Any], method: str, path: str
552
+ ) -> bool:
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
+ """
559
+ if not isinstance(openapi_spec, dict):
560
+ return False
561
+ paths = openapi_spec.get("paths", {})
562
+ if not isinstance(paths, dict):
563
+ return False
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)
307
584
 
308
585
 
309
- __all__ = ["OpenCodeClient", "OpenCodeProtocolError"]
586
+ __all__ = ["OpenCodeClient", "OpenCodeProtocolError", "OpenCodeApiProfile"]