codex-autorunner 0.1.0__py3-none-any.whl → 0.1.2__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 (173) hide show
  1. codex_autorunner/agents/__init__.py +21 -0
  2. codex_autorunner/agents/base.py +62 -0
  3. codex_autorunner/agents/codex/__init__.py +5 -0
  4. codex_autorunner/agents/codex/harness.py +220 -0
  5. codex_autorunner/agents/execution/policy.py +292 -0
  6. codex_autorunner/agents/factory.py +52 -0
  7. codex_autorunner/agents/opencode/__init__.py +18 -0
  8. codex_autorunner/agents/opencode/agent_config.py +104 -0
  9. codex_autorunner/agents/opencode/client.py +553 -0
  10. codex_autorunner/agents/opencode/events.py +67 -0
  11. codex_autorunner/agents/opencode/harness.py +263 -0
  12. codex_autorunner/agents/opencode/logging.py +209 -0
  13. codex_autorunner/agents/opencode/run_prompt.py +260 -0
  14. codex_autorunner/agents/opencode/runtime.py +1491 -0
  15. codex_autorunner/agents/opencode/supervisor.py +520 -0
  16. codex_autorunner/agents/orchestrator.py +358 -0
  17. codex_autorunner/agents/registry.py +130 -0
  18. codex_autorunner/agents/types.py +42 -0
  19. codex_autorunner/bootstrap.py +32 -23
  20. codex_autorunner/cli.py +389 -116
  21. codex_autorunner/codex_cli.py +5 -0
  22. codex_autorunner/core/about_car.py +20 -7
  23. codex_autorunner/core/app_server_events.py +192 -0
  24. codex_autorunner/core/app_server_logging.py +205 -0
  25. codex_autorunner/core/app_server_prompts.py +378 -0
  26. codex_autorunner/core/app_server_threads.py +195 -0
  27. codex_autorunner/core/circuit_breaker.py +183 -0
  28. codex_autorunner/core/config.py +888 -82
  29. codex_autorunner/core/doc_chat.py +1248 -349
  30. codex_autorunner/core/docs.py +83 -6
  31. codex_autorunner/core/engine.py +2037 -120
  32. codex_autorunner/core/exceptions.py +60 -0
  33. codex_autorunner/core/git_utils.py +28 -0
  34. codex_autorunner/core/hub.py +232 -99
  35. codex_autorunner/core/locks.py +230 -3
  36. codex_autorunner/core/logging_utils.py +14 -7
  37. codex_autorunner/core/optional_dependencies.py +7 -4
  38. codex_autorunner/core/patch_utils.py +224 -0
  39. codex_autorunner/core/path_utils.py +123 -0
  40. codex_autorunner/core/prompt.py +4 -31
  41. codex_autorunner/core/request_context.py +18 -0
  42. codex_autorunner/core/retry.py +61 -0
  43. codex_autorunner/core/review.py +888 -0
  44. codex_autorunner/core/review_context.py +164 -0
  45. codex_autorunner/core/run_index.py +217 -0
  46. codex_autorunner/core/runner_controller.py +56 -1
  47. codex_autorunner/core/runner_process.py +27 -1
  48. codex_autorunner/core/snapshot.py +136 -132
  49. codex_autorunner/core/sqlite_utils.py +32 -0
  50. codex_autorunner/core/state.py +379 -58
  51. codex_autorunner/core/text_delta_coalescer.py +43 -0
  52. codex_autorunner/core/update.py +15 -1
  53. codex_autorunner/core/usage.py +760 -69
  54. codex_autorunner/core/utils.py +167 -5
  55. codex_autorunner/discovery.py +115 -30
  56. codex_autorunner/integrations/app_server/client.py +161 -73
  57. codex_autorunner/integrations/app_server/env.py +110 -0
  58. codex_autorunner/integrations/app_server/supervisor.py +1 -0
  59. codex_autorunner/integrations/github/chatops.py +268 -0
  60. codex_autorunner/integrations/github/pr_flow.py +1314 -0
  61. codex_autorunner/integrations/github/service.py +269 -1
  62. codex_autorunner/integrations/telegram/adapter.py +395 -41
  63. codex_autorunner/integrations/telegram/config.py +161 -2
  64. codex_autorunner/integrations/telegram/constants.py +17 -0
  65. codex_autorunner/integrations/telegram/dispatch.py +82 -40
  66. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  67. codex_autorunner/integrations/telegram/handlers/callbacks.py +27 -2
  68. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +27 -0
  69. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  70. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2599 -0
  71. codex_autorunner/integrations/telegram/handlers/commands/files.py +1412 -0
  72. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  73. codex_autorunner/integrations/telegram/handlers/commands/github.py +2229 -0
  74. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  75. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  76. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  77. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +1243 -3422
  78. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +10 -12
  79. codex_autorunner/integrations/telegram/handlers/messages.py +398 -46
  80. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  81. codex_autorunner/integrations/telegram/handlers/selections.py +79 -1
  82. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  83. codex_autorunner/integrations/telegram/helpers.py +129 -121
  84. codex_autorunner/integrations/telegram/notifications.py +422 -26
  85. codex_autorunner/integrations/telegram/outbox.py +77 -56
  86. codex_autorunner/integrations/telegram/overflow.py +194 -0
  87. codex_autorunner/integrations/telegram/progress_stream.py +237 -0
  88. codex_autorunner/integrations/telegram/runtime.py +33 -20
  89. codex_autorunner/integrations/telegram/service.py +427 -34
  90. codex_autorunner/integrations/telegram/state.py +1225 -330
  91. codex_autorunner/integrations/telegram/transport.py +89 -9
  92. codex_autorunner/integrations/telegram/types.py +22 -2
  93. codex_autorunner/integrations/telegram/voice.py +14 -15
  94. codex_autorunner/manifest.py +48 -1
  95. codex_autorunner/routes/__init__.py +14 -0
  96. codex_autorunner/routes/agents.py +138 -0
  97. codex_autorunner/routes/app_server.py +132 -0
  98. codex_autorunner/routes/base.py +202 -47
  99. codex_autorunner/routes/docs.py +132 -26
  100. codex_autorunner/routes/github.py +136 -6
  101. codex_autorunner/routes/repos.py +76 -0
  102. codex_autorunner/routes/review.py +148 -0
  103. codex_autorunner/routes/runs.py +250 -0
  104. codex_autorunner/routes/sessions.py +49 -10
  105. codex_autorunner/routes/settings.py +169 -0
  106. codex_autorunner/routes/shared.py +149 -10
  107. codex_autorunner/routes/system.py +16 -0
  108. codex_autorunner/routes/voice.py +5 -13
  109. codex_autorunner/server.py +0 -7
  110. codex_autorunner/spec_ingest.py +778 -79
  111. codex_autorunner/static/agentControls.js +351 -0
  112. codex_autorunner/static/app.js +85 -78
  113. codex_autorunner/static/autoRefresh.js +118 -147
  114. codex_autorunner/static/bootstrap.js +117 -99
  115. codex_autorunner/static/bus.js +16 -17
  116. codex_autorunner/static/cache.js +26 -41
  117. codex_autorunner/static/constants.js +44 -45
  118. codex_autorunner/static/dashboard.js +723 -717
  119. codex_autorunner/static/docChatActions.js +287 -0
  120. codex_autorunner/static/docChatEvents.js +300 -0
  121. codex_autorunner/static/docChatRender.js +205 -0
  122. codex_autorunner/static/docChatStream.js +361 -0
  123. codex_autorunner/static/docs.js +18 -1512
  124. codex_autorunner/static/docsClipboard.js +69 -0
  125. codex_autorunner/static/docsCrud.js +257 -0
  126. codex_autorunner/static/docsDocUpdates.js +62 -0
  127. codex_autorunner/static/docsDrafts.js +16 -0
  128. codex_autorunner/static/docsElements.js +69 -0
  129. codex_autorunner/static/docsInit.js +285 -0
  130. codex_autorunner/static/docsParse.js +160 -0
  131. codex_autorunner/static/docsSnapshot.js +87 -0
  132. codex_autorunner/static/docsSpecIngest.js +263 -0
  133. codex_autorunner/static/docsState.js +127 -0
  134. codex_autorunner/static/docsThreadRegistry.js +44 -0
  135. codex_autorunner/static/docsUi.js +153 -0
  136. codex_autorunner/static/docsVoice.js +56 -0
  137. codex_autorunner/static/env.js +29 -79
  138. codex_autorunner/static/github.js +489 -153
  139. codex_autorunner/static/hub.js +1235 -1331
  140. codex_autorunner/static/index.html +407 -49
  141. codex_autorunner/static/liveUpdates.js +58 -0
  142. codex_autorunner/static/loader.js +26 -26
  143. codex_autorunner/static/logs.js +598 -610
  144. codex_autorunner/static/mobileCompact.js +215 -263
  145. codex_autorunner/static/review.js +157 -0
  146. codex_autorunner/static/runs.js +418 -0
  147. codex_autorunner/static/settings.js +341 -0
  148. codex_autorunner/static/snapshot.js +104 -96
  149. codex_autorunner/static/state.js +76 -69
  150. codex_autorunner/static/styles.css +1905 -436
  151. codex_autorunner/static/tabs.js +34 -43
  152. codex_autorunner/static/terminal.js +6 -15
  153. codex_autorunner/static/terminalManager.js +3532 -3468
  154. codex_autorunner/static/todoPreview.js +25 -23
  155. codex_autorunner/static/utils.js +567 -534
  156. codex_autorunner/static/voice.js +498 -537
  157. codex_autorunner/voice/capture.py +7 -7
  158. codex_autorunner/voice/service.py +51 -9
  159. codex_autorunner/web/app.py +907 -172
  160. codex_autorunner/web/hub_jobs.py +13 -2
  161. codex_autorunner/web/middleware.py +54 -18
  162. codex_autorunner/web/pty_session.py +26 -13
  163. codex_autorunner/web/schemas.py +144 -0
  164. codex_autorunner/web/static_assets.py +57 -0
  165. codex_autorunner/web/static_refresh.py +86 -0
  166. {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/METADATA +17 -8
  167. codex_autorunner-0.1.2.dist-info/RECORD +222 -0
  168. {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/WHEEL +1 -1
  169. codex_autorunner/static/types.d.ts +0 -8
  170. codex_autorunner-0.1.0.dist-info/RECORD +0 -147
  171. {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/entry_points.txt +0 -0
  172. {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/licenses/LICENSE +0 -0
  173. {codex_autorunner-0.1.0.dist-info → codex_autorunner-0.1.2.dist-info}/top_level.txt +0 -0
@@ -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"]
@@ -0,0 +1,553 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import dataclasses
5
+ import json
6
+ import logging
7
+ from typing import Any, AsyncIterator, Iterable, Optional
8
+
9
+ import httpx
10
+
11
+ from ...core.logging_utils import log_event
12
+ from .events import SSEEvent, parse_sse_lines
13
+
14
+ _MAX_INVALID_JSON_PREVIEW_BYTES = 512
15
+
16
+
17
+ @dataclasses.dataclass
18
+ class OpenCodeApiProfile:
19
+ """Detected OpenCode API capabilities from OpenAPI spec."""
20
+
21
+ supports_prompt_async: bool = True
22
+ supports_global_endpoints: bool = True
23
+ spec_fetched: bool = False
24
+
25
+
26
+ class OpenCodeProtocolError(Exception):
27
+ def __init__(
28
+ self,
29
+ message: str,
30
+ *,
31
+ status_code: Optional[int] = None,
32
+ content_type: Optional[str] = None,
33
+ body_preview: Optional[str] = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.status_code = status_code
37
+ self.content_type = content_type
38
+ self.body_preview = body_preview
39
+
40
+
41
+ def _normalize_sse_event(event: SSEEvent) -> SSEEvent:
42
+ event_type = event.event
43
+ raw_data = event.data or ""
44
+ payload_obj: Optional[dict[str, Any]] = None
45
+ try:
46
+ payload_obj = json.loads(raw_data) if raw_data else None
47
+ except (json.JSONDecodeError, TypeError):
48
+ payload_obj = None
49
+
50
+ if isinstance(payload_obj, dict) and isinstance(payload_obj.get("payload"), dict):
51
+ payload_obj = payload_obj["payload"]
52
+
53
+ if isinstance(payload_obj, dict):
54
+ payload_type = payload_obj.get("type")
55
+ if isinstance(payload_type, str) and payload_type:
56
+ event_type = payload_type
57
+ raw_data = json.dumps(payload_obj)
58
+
59
+ return SSEEvent(
60
+ event=event_type,
61
+ data=raw_data,
62
+ id=event.id,
63
+ retry=event.retry,
64
+ )
65
+
66
+
67
+ class OpenCodeClient:
68
+ def __init__(
69
+ self,
70
+ base_url: str,
71
+ *,
72
+ auth: Optional[tuple[str, str]] = None,
73
+ timeout: Optional[float] = None,
74
+ logger: Optional[logging.Logger] = None,
75
+ ) -> None:
76
+ self._client = httpx.AsyncClient(
77
+ base_url=base_url,
78
+ auth=auth,
79
+ timeout=timeout,
80
+ )
81
+ self._logger = logger or logging.getLogger(__name__)
82
+ self._api_profile: Optional[OpenCodeApiProfile] = None
83
+ self._api_profile_lock = asyncio.Lock()
84
+
85
+ async def close(self) -> None:
86
+ await self._client.aclose()
87
+
88
+ async def detect_api_shape(self) -> OpenCodeApiProfile:
89
+ """Detect OpenCode API capabilities by fetching and parsing OpenAPI spec.
90
+ Results are cached for the lifetime of the client instance.
91
+ Thread-safe: multiple concurrent calls will wait for first detection to complete.
92
+ """
93
+ async with self._api_profile_lock:
94
+ if self._api_profile is not None:
95
+ return self._api_profile
96
+
97
+ profile = OpenCodeApiProfile()
98
+ try:
99
+ spec = await self.fetch_openapi_spec()
100
+ profile.spec_fetched = True
101
+
102
+ if isinstance(spec, dict):
103
+ # Check if /session/{id}/prompt_async exists
104
+ profile.supports_prompt_async = self.has_endpoint(
105
+ spec, "post", "/session/{session_id}/prompt_async"
106
+ )
107
+
108
+ # Check if /global/* endpoints exist
109
+ profile.supports_global_endpoints = self.has_endpoint(
110
+ spec, "get", "/global/health"
111
+ ) or self.has_endpoint(spec, "get", "/global/event")
112
+
113
+ log_event(
114
+ self._logger,
115
+ logging.INFO,
116
+ "opencode.api_shape_detected",
117
+ supports_prompt_async=profile.supports_prompt_async,
118
+ supports_global_endpoints=profile.supports_global_endpoints,
119
+ )
120
+ except Exception as exc:
121
+ self._logger.warning(
122
+ "Failed to detect API shape, assuming modern OpenCode: %s", exc
123
+ )
124
+ # Default to assuming modern OpenCode with all features
125
+ profile.supports_prompt_async = True
126
+ profile.supports_global_endpoints = True
127
+
128
+ self._api_profile = profile
129
+ return profile
130
+
131
+ def _get_api_profile(self) -> OpenCodeApiProfile:
132
+ """Get API profile, detecting if needed. Synchronous for use in sync methods."""
133
+ if self._api_profile is None:
134
+ # Return default profile if not yet detected
135
+ return OpenCodeApiProfile()
136
+ return self._api_profile
137
+
138
+ def _dir_params(self, directory: Optional[str]) -> dict[str, str]:
139
+ return {"directory": directory} if directory else {}
140
+
141
+ async def _request(
142
+ self,
143
+ method: str,
144
+ path: str,
145
+ *,
146
+ params: Optional[dict[str, Any]] = None,
147
+ json_body: Optional[dict[str, Any]] = None,
148
+ expect_json: bool = True,
149
+ ) -> Any:
150
+ response = await self._client.request(
151
+ method, path, params=params, json=json_body
152
+ )
153
+ response.raise_for_status()
154
+ raw = response.content
155
+ if not raw or not raw.strip():
156
+ return None
157
+ try:
158
+ return json.loads(raw)
159
+ except json.JSONDecodeError as exc:
160
+ self._log_invalid_json(
161
+ method,
162
+ path,
163
+ response,
164
+ raw,
165
+ expect_json=expect_json,
166
+ )
167
+ if expect_json:
168
+ preview = (
169
+ raw[:_MAX_INVALID_JSON_PREVIEW_BYTES]
170
+ .decode("utf-8", errors="replace")
171
+ .strip()
172
+ )
173
+ content_type = response.headers.get("content-type")
174
+ hint = ""
175
+ if content_type and "text/html" in content_type.lower():
176
+ hint = (
177
+ " Response looks like HTML; the OpenCode server may have "
178
+ "proxied the request instead of handling an API route."
179
+ )
180
+ elif preview.startswith("<"):
181
+ hint = (
182
+ " Response looks like HTML; check that the OpenCode API "
183
+ "endpoint is correct."
184
+ )
185
+ raise OpenCodeProtocolError(
186
+ f"OpenCode returned invalid JSON.{hint}",
187
+ status_code=response.status_code,
188
+ content_type=content_type,
189
+ body_preview=preview or None,
190
+ ) from exc
191
+ return None
192
+
193
+ def _log_invalid_json(
194
+ self,
195
+ method: str,
196
+ path: str,
197
+ response: httpx.Response,
198
+ raw: bytes,
199
+ *,
200
+ expect_json: bool,
201
+ ) -> None:
202
+ preview = raw[:_MAX_INVALID_JSON_PREVIEW_BYTES].decode(
203
+ "utf-8", errors="replace"
204
+ )
205
+ log_event(
206
+ self._logger,
207
+ logging.WARNING,
208
+ "opencode.response.invalid_json",
209
+ method=method,
210
+ path=path,
211
+ status_code=response.status_code,
212
+ content_length=len(raw),
213
+ content_type=response.headers.get("content-type"),
214
+ expect_json=expect_json,
215
+ preview=preview,
216
+ )
217
+
218
+ async def providers(self, directory: Optional[str] = None) -> Any:
219
+ return await self._request(
220
+ "GET",
221
+ "/config/providers",
222
+ params=self._dir_params(directory),
223
+ expect_json=True,
224
+ )
225
+
226
+ async def create_session(
227
+ self,
228
+ *,
229
+ title: Optional[str] = None,
230
+ directory: Optional[str] = None,
231
+ ) -> Any:
232
+ payload: dict[str, Any] = {}
233
+ if title:
234
+ payload["title"] = title
235
+ if directory:
236
+ payload["directory"] = directory
237
+ return await self._request(
238
+ "POST", "/session", json_body=payload, expect_json=True
239
+ )
240
+
241
+ async def list_sessions(self, directory: Optional[str] = None) -> Any:
242
+ return await self._request(
243
+ "GET", "/session", params=self._dir_params(directory), expect_json=True
244
+ )
245
+
246
+ async def get_session(self, session_id: str) -> Any:
247
+ return await self._request("GET", f"/session/{session_id}", expect_json=True)
248
+
249
+ async def session_status(self, *, directory: Optional[str] = None) -> Any:
250
+ return await self._request(
251
+ "GET",
252
+ "/session/status",
253
+ params=self._dir_params(directory),
254
+ expect_json=True,
255
+ )
256
+
257
+ async def send_message(
258
+ self,
259
+ session_id: str,
260
+ *,
261
+ message: str,
262
+ agent: Optional[str] = None,
263
+ model: Optional[dict[str, str]] = None,
264
+ variant: Optional[str] = None,
265
+ ) -> Any:
266
+ payload: dict[str, Any] = {
267
+ "parts": [{"type": "text", "text": message}],
268
+ }
269
+ if agent:
270
+ payload["agent"] = agent
271
+ if model:
272
+ payload["model"] = model
273
+ if variant:
274
+ payload["variant"] = variant
275
+ return await self._request(
276
+ "POST",
277
+ f"/session/{session_id}/message",
278
+ json_body=payload,
279
+ expect_json=False,
280
+ )
281
+
282
+ async def prompt(
283
+ self,
284
+ session_id: str,
285
+ *,
286
+ message: str,
287
+ agent: Optional[str] = None,
288
+ model: Optional[dict[str, str]] = None,
289
+ variant: Optional[str] = None,
290
+ ) -> Any:
291
+ payload: dict[str, Any] = {
292
+ "parts": [{"type": "text", "text": message}],
293
+ }
294
+ if agent:
295
+ payload["agent"] = agent
296
+ if model:
297
+ payload["model"] = model
298
+ if variant:
299
+ payload["variant"] = variant
300
+
301
+ profile = await self.detect_api_shape()
302
+ if profile.supports_prompt_async:
303
+ return await self._request(
304
+ "POST",
305
+ f"/session/{session_id}/prompt_async",
306
+ json_body=payload,
307
+ expect_json=False,
308
+ )
309
+ else:
310
+ return await self._request(
311
+ "POST",
312
+ f"/session/{session_id}/message",
313
+ json_body=payload,
314
+ expect_json=True,
315
+ )
316
+
317
+ async def prompt_async(
318
+ self,
319
+ session_id: str,
320
+ *,
321
+ message: str,
322
+ agent: Optional[str] = None,
323
+ model: Optional[dict[str, str]] = None,
324
+ variant: Optional[str] = None,
325
+ ) -> Any:
326
+ payload: dict[str, Any] = {
327
+ "parts": [{"type": "text", "text": message}],
328
+ }
329
+ if agent:
330
+ payload["agent"] = agent
331
+ if model:
332
+ payload["model"] = model
333
+ if variant:
334
+ payload["variant"] = variant
335
+
336
+ profile = await self.detect_api_shape()
337
+ if profile.supports_prompt_async:
338
+ return await self._request(
339
+ "POST",
340
+ f"/session/{session_id}/prompt_async",
341
+ json_body=payload,
342
+ expect_json=False,
343
+ )
344
+ else:
345
+ return await self._request(
346
+ "POST",
347
+ f"/session/{session_id}/message",
348
+ json_body=payload,
349
+ expect_json=True,
350
+ )
351
+
352
+ async def send_command(
353
+ self,
354
+ session_id: str,
355
+ *,
356
+ command: str,
357
+ arguments: Optional[str] = None,
358
+ model: Optional[str] = None,
359
+ agent: Optional[str] = None,
360
+ ) -> Any:
361
+ payload: dict[str, Any] = {
362
+ "command": command,
363
+ "arguments": arguments or "",
364
+ }
365
+ if model:
366
+ payload["model"] = model
367
+ if agent:
368
+ payload["agent"] = agent
369
+ return await self._request(
370
+ "POST",
371
+ f"/session/{session_id}/command",
372
+ json_body=payload,
373
+ expect_json=False,
374
+ )
375
+
376
+ async def summarize(
377
+ self,
378
+ session_id: str,
379
+ *,
380
+ provider_id: str,
381
+ model_id: str,
382
+ auto: Optional[bool] = None,
383
+ ) -> Any:
384
+ payload: dict[str, Any] = {
385
+ "providerID": provider_id,
386
+ "modelID": model_id,
387
+ }
388
+ if auto is not None:
389
+ payload["auto"] = auto
390
+ return await self._request(
391
+ "POST",
392
+ f"/session/{session_id}/summarize",
393
+ json_body=payload,
394
+ expect_json=True,
395
+ )
396
+
397
+ async def respond_permission(
398
+ self,
399
+ *,
400
+ request_id: str,
401
+ reply: str,
402
+ message: Optional[str] = None,
403
+ ) -> Any:
404
+ payload: dict[str, Any] = {"reply": reply}
405
+ if message:
406
+ payload["message"] = message
407
+ return await self._request(
408
+ "POST",
409
+ f"/permission/{request_id}/reply",
410
+ json_body=payload,
411
+ expect_json=False,
412
+ )
413
+
414
+ async def list_questions(self) -> Any:
415
+ return await self._request("GET", "/question", expect_json=True)
416
+
417
+ async def reply_question(self, request_id: str, *, answers: list[list[str]]) -> Any:
418
+ payload: dict[str, Any] = {"answers": answers}
419
+ return await self._request(
420
+ "POST",
421
+ f"/question/{request_id}/reply",
422
+ json_body=payload,
423
+ expect_json=False,
424
+ )
425
+
426
+ async def reject_question(self, request_id: str) -> Any:
427
+ return await self._request(
428
+ "POST",
429
+ f"/question/{request_id}/reject",
430
+ expect_json=False,
431
+ )
432
+
433
+ async def abort(self, session_id: str) -> Any:
434
+ return await self._request(
435
+ "POST", f"/session/{session_id}/abort", expect_json=False
436
+ )
437
+
438
+ async def health(self) -> Any:
439
+ """Check OpenCode server health using /global/health or /health endpoint."""
440
+ profile = await self.detect_api_shape()
441
+ if profile.supports_global_endpoints:
442
+ return await self._request("GET", "/global/health", expect_json=True)
443
+ else:
444
+ return await self._request("GET", "/health", expect_json=True)
445
+
446
+ async def dispose(self, session_id: str) -> Any:
447
+ """Dispose of a session using /global/dispose/{id} or /session/{id}/dispose endpoint."""
448
+ profile = await self.detect_api_shape()
449
+ if profile.supports_global_endpoints:
450
+ return await self._request(
451
+ "POST", f"/global/dispose/{session_id}", expect_json=False
452
+ )
453
+ else:
454
+ return await self._request(
455
+ "POST", f"/session/{session_id}/dispose", expect_json=False
456
+ )
457
+
458
+ async def stream_events(
459
+ self,
460
+ *,
461
+ directory: Optional[str] = None,
462
+ ready_event: Optional[asyncio.Event] = None,
463
+ paths: Optional[Iterable[str]] = None,
464
+ ) -> AsyncIterator[SSEEvent]:
465
+ params = self._dir_params(directory)
466
+
467
+ if paths is not None:
468
+ event_paths = list(paths)
469
+ else:
470
+ profile = await self.detect_api_shape()
471
+ if profile.supports_global_endpoints:
472
+ event_paths = (
473
+ ["/event", "/global/event"]
474
+ if directory
475
+ else ["/global/event", "/event"]
476
+ )
477
+ else:
478
+ event_paths = ["/event"]
479
+
480
+ last_error: Optional[BaseException] = None
481
+ for path in event_paths:
482
+ try:
483
+ async with self._client.stream(
484
+ "GET", path, params=params, timeout=None
485
+ ) as response:
486
+ response.raise_for_status()
487
+ if ready_event is not None:
488
+ ready_event.set()
489
+ async for sse in parse_sse_lines(response.aiter_lines()):
490
+ yield _normalize_sse_event(sse)
491
+ return
492
+ except httpx.HTTPStatusError as exc:
493
+ last_error = exc
494
+ status_code = exc.response.status_code
495
+ if status_code in (404, 405):
496
+ continue
497
+ raise
498
+ except Exception as exc:
499
+ last_error = exc
500
+ raise
501
+ if ready_event is not None and not ready_event.is_set():
502
+ ready_event.set()
503
+ if last_error is not None:
504
+ raise last_error
505
+
506
+ async def fetch_openapi_spec(self) -> dict[str, Any]:
507
+ """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
537
+
538
+ def has_endpoint(
539
+ self, openapi_spec: dict[str, Any], method: str, path: str
540
+ ) -> bool:
541
+ """Check if endpoint is available in OpenAPI spec."""
542
+ if not isinstance(openapi_spec, dict):
543
+ return False
544
+ paths = openapi_spec.get("paths", {})
545
+ if not isinstance(paths, dict):
546
+ return False
547
+ path_info = paths.get(path)
548
+ if not isinstance(path_info, dict):
549
+ return False
550
+ return method in path_info
551
+
552
+
553
+ __all__ = ["OpenCodeClient", "OpenCodeProtocolError", "OpenCodeApiProfile"]