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
@@ -2,19 +2,21 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import logging
5
- import os
6
5
  import re
7
6
  import time
8
7
  from dataclasses import dataclass
9
8
  from pathlib import Path
10
- from typing import Optional, Sequence
9
+ from typing import Any, Mapping, Optional, Sequence
10
+
11
+ import httpx
11
12
 
12
13
  from ...core.logging_utils import log_event
13
- from ...core.utils import subprocess_env
14
+ from ...core.supervisor_utils import evict_lru_handle_locked, pop_idle_handles_locked
15
+ from ...core.utils import infer_home_from_workspace, subprocess_env
14
16
  from ...workspace import canonical_workspace_root, workspace_id_for_path
15
17
  from .client import OpenCodeClient
16
18
 
17
- _LISTENING_RE = re.compile(r"listening on (http://[^\s]+)")
19
+ _LISTENING_RE = re.compile(r"listening on (https?://[^\s]+)")
18
20
 
19
21
 
20
22
  class OpenCodeSupervisorError(Exception):
@@ -28,6 +30,9 @@ class OpenCodeHandle:
28
30
  process: Optional[asyncio.subprocess.Process]
29
31
  client: Optional[OpenCodeClient]
30
32
  base_url: Optional[str]
33
+ health_info: Optional[dict[str, Any]]
34
+ version: Optional[str]
35
+ openapi_spec: Optional[dict[str, Any]]
31
36
  start_lock: asyncio.Lock
32
37
  stdout_task: Optional[asyncio.Task[None]] = None
33
38
  started: bool = False
@@ -46,15 +51,31 @@ class OpenCodeSupervisor:
46
51
  idle_ttl_seconds: Optional[float] = None,
47
52
  username: Optional[str] = None,
48
53
  password: Optional[str] = None,
54
+ base_env: Optional[Mapping[str, str]] = None,
55
+ base_url: Optional[str] = None,
56
+ subagent_models: Optional[Mapping[str, str]] = None,
57
+ session_stall_timeout_seconds: Optional[float] = None,
49
58
  ) -> None:
50
59
  self._command = [str(arg) for arg in command]
51
60
  self._logger = logger or logging.getLogger(__name__)
52
61
  self._request_timeout = request_timeout
53
62
  self._max_handles = max_handles
54
63
  self._idle_ttl_seconds = idle_ttl_seconds
55
- self._auth = (username, password) if username and password else None
64
+ self._session_stall_timeout_seconds = session_stall_timeout_seconds
65
+ if password and not username:
66
+ username = "opencode"
67
+ self._auth: Optional[tuple[str, str]] = (
68
+ (username, password) if password and username else None
69
+ )
70
+ self._base_env = base_env
71
+ self._base_url = base_url
72
+ self._subagent_models = subagent_models or {}
56
73
  self._handles: dict[str, OpenCodeHandle] = {}
57
- 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
58
79
 
59
80
  async def get_client(self, workspace_root: Path) -> OpenCodeClient:
60
81
  canonical_root = canonical_workspace_root(workspace_root)
@@ -67,7 +88,7 @@ class OpenCodeSupervisor:
67
88
  return handle.client
68
89
 
69
90
  async def close_all(self) -> None:
70
- async with self._lock:
91
+ async with self._get_lock():
71
92
  handles = list(self._handles.values())
72
93
  self._handles = {}
73
94
  for handle in handles:
@@ -86,7 +107,7 @@ class OpenCodeSupervisor:
86
107
  async def mark_turn_started(self, workspace_root: Path) -> None:
87
108
  canonical_root = canonical_workspace_root(workspace_root)
88
109
  workspace_id = workspace_id_for_path(canonical_root)
89
- async with self._lock:
110
+ async with self._get_lock():
90
111
  handle = self._handles.get(workspace_id)
91
112
  if handle is None:
92
113
  return
@@ -96,7 +117,7 @@ class OpenCodeSupervisor:
96
117
  async def mark_turn_finished(self, workspace_root: Path) -> None:
97
118
  canonical_root = canonical_workspace_root(workspace_root)
98
119
  workspace_id = workspace_id_for_path(canonical_root)
99
- async with self._lock:
120
+ async with self._get_lock():
100
121
  handle = self._handles.get(workspace_id)
101
122
  if handle is None:
102
123
  return
@@ -104,6 +125,34 @@ class OpenCodeSupervisor:
104
125
  handle.active_turns -= 1
105
126
  handle.last_used_at = time.monotonic()
106
127
 
128
+ async def ensure_subagent_config(
129
+ self,
130
+ workspace_root: Path,
131
+ agent_id: str,
132
+ model: Optional[str] = None,
133
+ ) -> None:
134
+ """Ensure subagent agent config file exists with correct model.
135
+
136
+ Args:
137
+ workspace_root: Path to workspace root
138
+ agent_id: Agent ID to configure (e.g., "subagent")
139
+ model: Optional model override (defaults to subagent_models if not provided)
140
+ """
141
+ if model is None:
142
+ model = self._subagent_models.get(agent_id)
143
+ if not model:
144
+ return
145
+
146
+ from .agent_config import ensure_agent_config
147
+
148
+ await ensure_agent_config(
149
+ workspace_root=workspace_root,
150
+ agent_id=agent_id,
151
+ model=model,
152
+ title=agent_id,
153
+ description=f"Subagent for {agent_id} tasks",
154
+ )
155
+
107
156
  async def _close_handle(self, handle: OpenCodeHandle, *, reason: str) -> None:
108
157
  try:
109
158
  idle_seconds = None
@@ -147,7 +196,7 @@ class OpenCodeSupervisor:
147
196
  ) -> OpenCodeHandle:
148
197
  handles_to_close: list[OpenCodeHandle] = []
149
198
  evicted_id: Optional[str] = None
150
- async with self._lock:
199
+ async with self._get_lock():
151
200
  existing = self._handles.get(workspace_id)
152
201
  if existing is not None:
153
202
  existing.last_used_at = time.monotonic()
@@ -163,6 +212,9 @@ class OpenCodeSupervisor:
163
212
  process=None,
164
213
  client=None,
165
214
  base_url=None,
215
+ health_info=None,
216
+ version=None,
217
+ openapi_spec=None,
166
218
  start_lock=asyncio.Lock(),
167
219
  stdout_task=None,
168
220
  last_used_at=time.monotonic(),
@@ -181,9 +233,97 @@ class OpenCodeSupervisor:
181
233
  async with handle.start_lock:
182
234
  if handle.started and handle.process and handle.process.returncode is None:
183
235
  return
184
- await self._start_process(handle)
236
+ if self._base_url:
237
+ await self._ensure_started_base_url(handle)
238
+ else:
239
+ await self._start_process(handle)
240
+
241
+ async def _ensure_started_base_url(self, handle: OpenCodeHandle) -> None:
242
+ base_url = self._base_url
243
+ handle.health_info = None
244
+ handle.version = None
245
+
246
+ if not base_url:
247
+ return
248
+
249
+ try:
250
+ health_url = f"{base_url.rstrip('/')}/global/health"
251
+ async with httpx.AsyncClient(
252
+ timeout=self._request_timeout or 10.0
253
+ ) as client:
254
+ response = await client.get(health_url)
255
+ response.raise_for_status()
256
+
257
+ try:
258
+ handle.health_info = response.json() if response.content else {}
259
+ except Exception:
260
+ handle.health_info = {}
261
+
262
+ handle.version = str(handle.health_info.get("version", "unknown"))
263
+
264
+ log_event(
265
+ self._logger,
266
+ logging.INFO,
267
+ "opencode.health_check",
268
+ base_url=base_url,
269
+ version=handle.version,
270
+ health_info=bool(handle.health_info),
271
+ exc=None,
272
+ )
273
+ handle.base_url = base_url
274
+ handle.client = OpenCodeClient(
275
+ base_url,
276
+ auth=self._auth,
277
+ timeout=self._request_timeout,
278
+ logger=self._logger,
279
+ )
280
+ try:
281
+ handle.openapi_spec = await handle.client.fetch_openapi_spec()
282
+ log_event(
283
+ self._logger,
284
+ logging.INFO,
285
+ "opencode.openapi.fetched",
286
+ base_url=base_url,
287
+ endpoints=(
288
+ len(handle.openapi_spec.get("paths", {}))
289
+ if isinstance(handle.openapi_spec, dict)
290
+ else 0
291
+ ),
292
+ )
293
+ except Exception as exc:
294
+ log_event(
295
+ self._logger,
296
+ logging.WARNING,
297
+ "opencode.openapi.fetch_failed",
298
+ base_url=base_url,
299
+ exc=exc,
300
+ )
301
+ handle.openapi_spec = {}
302
+ handle.started = True
303
+ except Exception as exc:
304
+ log_event(
305
+ self._logger,
306
+ logging.WARNING,
307
+ "opencode.health_check.failed",
308
+ base_url=base_url,
309
+ exc=exc,
310
+ )
311
+ raise OpenCodeSupervisorError(
312
+ f"OpenCode health check failed: {exc}"
313
+ ) from exc
185
314
 
186
315
  async def _start_process(self, handle: OpenCodeHandle) -> None:
316
+ if self._base_url:
317
+ handle.health_info = {}
318
+ handle.version = "external"
319
+ log_event(
320
+ self._logger,
321
+ logging.INFO,
322
+ "opencode.external_mode",
323
+ base_url=self._base_url,
324
+ )
325
+ return
326
+
187
327
  env = self._build_opencode_env(handle.workspace_root)
188
328
  process = await asyncio.create_subprocess_exec(
189
329
  *self._command,
@@ -206,6 +346,28 @@ class OpenCodeSupervisor:
206
346
  timeout=self._request_timeout,
207
347
  logger=self._logger,
208
348
  )
349
+ try:
350
+ handle.openapi_spec = await handle.client.fetch_openapi_spec()
351
+ log_event(
352
+ self._logger,
353
+ logging.INFO,
354
+ "opencode.openapi.fetched",
355
+ base_url=base_url,
356
+ endpoints=(
357
+ len(handle.openapi_spec.get("paths", {}))
358
+ if isinstance(handle.openapi_spec, dict)
359
+ else 0
360
+ ),
361
+ )
362
+ except Exception as exc:
363
+ log_event(
364
+ self._logger,
365
+ logging.WARNING,
366
+ "opencode.openapi.fetch_failed",
367
+ base_url=base_url,
368
+ exc=exc,
369
+ )
370
+ handle.openapi_spec = {}
209
371
  self._start_stdout_drain(handle)
210
372
  handle.started = True
211
373
  except Exception:
@@ -219,8 +381,8 @@ class OpenCodeSupervisor:
219
381
  raise
220
382
 
221
383
  def _build_opencode_env(self, workspace_root: Path) -> dict[str, str]:
222
- env = subprocess_env()
223
- inferred_home = self._infer_home_from_workspace(workspace_root)
384
+ env = subprocess_env(base_env=self._base_env)
385
+ inferred_home = infer_home_from_workspace(workspace_root)
224
386
  if inferred_home is None:
225
387
  return env
226
388
  inferred_auth = inferred_home / ".local" / "share" / "opencode" / "auth.json"
@@ -241,21 +403,6 @@ class OpenCodeSupervisor:
241
403
  )
242
404
  return env
243
405
 
244
- def _infer_home_from_workspace(self, workspace_root: Path) -> Optional[Path]:
245
- resolved = workspace_root.resolve()
246
- parts = resolved.parts
247
- if (
248
- len(parts) >= 3
249
- and parts[0] == os.path.sep
250
- and parts[1]
251
- in (
252
- "Users",
253
- "home",
254
- )
255
- ):
256
- return Path(parts[0]) / parts[1] / parts[2]
257
- return None
258
-
259
406
  def _opencode_auth_path_for_env(self, env: dict[str, str]) -> Optional[Path]:
260
407
  data_home = env.get("XDG_DATA_HOME")
261
408
  if not data_home:
@@ -330,53 +477,32 @@ class OpenCodeSupervisor:
330
477
  return match.group(1)
331
478
 
332
479
  async def _pop_idle_handles(self) -> list[OpenCodeHandle]:
333
- async with self._lock:
480
+ async with self._get_lock():
334
481
  return self._pop_idle_handles_locked()
335
482
 
483
+ def _get_lock(self) -> asyncio.Lock:
484
+ if self._lock is None:
485
+ self._lock = asyncio.Lock()
486
+ return self._lock
487
+
336
488
  def _pop_idle_handles_locked(self) -> list[OpenCodeHandle]:
337
- if not self._idle_ttl_seconds or self._idle_ttl_seconds <= 0:
338
- return []
339
- cutoff = time.monotonic() - self._idle_ttl_seconds
340
- stale: list[OpenCodeHandle] = []
341
- for handle in list(self._handles.values()):
342
- if handle.active_turns:
343
- log_event(
344
- self._logger,
345
- logging.INFO,
346
- "opencode.handle.prune.skipped",
347
- reason="active_turns",
348
- workspace_id=handle.workspace_id,
349
- workspace_root=str(handle.workspace_root),
350
- active_turns=handle.active_turns,
351
- )
352
- continue
353
- if handle.last_used_at and handle.last_used_at < cutoff:
354
- self._handles.pop(handle.workspace_id, None)
355
- stale.append(handle)
356
- 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
+ )
357
497
 
358
498
  def _evict_lru_handle_locked(self) -> Optional[OpenCodeHandle]:
359
- if not self._max_handles or self._max_handles <= 0:
360
- return None
361
- if len(self._handles) < self._max_handles:
362
- return None
363
- lru_handle = min(
364
- self._handles.values(),
365
- key=lambda handle: handle.last_used_at or 0.0,
366
- )
367
- log_event(
499
+ return evict_lru_handle_locked(
500
+ self._handles,
501
+ self._max_handles,
368
502
  self._logger,
369
- logging.INFO,
370
- "opencode.handle.evicted",
371
- reason="max_handles",
372
- workspace_id=lru_handle.workspace_id,
373
- workspace_root=str(lru_handle.workspace_root),
374
- max_handles=self._max_handles,
375
- handle_count=len(self._handles),
376
- last_used_at=lru_handle.last_used_at,
503
+ "opencode",
504
+ last_used_at_getter=lambda h: h.last_used_at or 0.0,
377
505
  )
378
- self._handles.pop(lru_handle.workspace_id, None)
379
- return lru_handle
380
506
 
381
507
 
382
508
  __all__ = ["OpenCodeHandle", "OpenCodeSupervisor", "OpenCodeSupervisorError"]
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.metadata
4
+ import logging
5
+ from dataclasses import dataclass
6
+ from typing import Any, Callable, Iterable, Literal, Optional
7
+
8
+ from ..plugin_api import CAR_AGENT_ENTRYPOINT_GROUP, CAR_PLUGIN_API_VERSION
9
+ from .base import AgentHarness
10
+ from .codex.harness import CodexHarness
11
+ from .opencode.harness import OpenCodeHarness
12
+
13
+ _logger = logging.getLogger(__name__)
14
+
15
+ AgentCapability = Literal[
16
+ "threads",
17
+ "turns",
18
+ "review",
19
+ "model_listing",
20
+ "event_streaming",
21
+ "approvals",
22
+ ]
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class AgentDescriptor:
27
+ """A registered agent backend.
28
+
29
+ Built-in backends live in `_BUILTIN_AGENTS`. Additional backends MAY be loaded
30
+ via Python entry points (see `CAR_AGENT_ENTRYPOINT_GROUP`).
31
+
32
+ Plugins SHOULD set `plugin_api_version` to `CAR_PLUGIN_API_VERSION`.
33
+ """
34
+
35
+ id: str
36
+ name: str
37
+ capabilities: frozenset[AgentCapability]
38
+ make_harness: Callable[[Any], AgentHarness]
39
+ healthcheck: Optional[Callable[[Any], bool]] = None
40
+ plugin_api_version: int = CAR_PLUGIN_API_VERSION
41
+
42
+
43
+ def _make_codex_harness(ctx: Any) -> AgentHarness:
44
+ supervisor = ctx.app_server_supervisor
45
+ events = ctx.app_server_events
46
+ if supervisor is None or events is None:
47
+ raise RuntimeError("Codex harness unavailable: supervisor or events missing")
48
+ return CodexHarness(supervisor, events)
49
+
50
+
51
+ def _make_opencode_harness(ctx: Any) -> AgentHarness:
52
+ supervisor = ctx.opencode_supervisor
53
+ if supervisor is None:
54
+ raise RuntimeError("OpenCode harness unavailable: supervisor missing")
55
+ return OpenCodeHarness(supervisor)
56
+
57
+
58
+ def _check_codex_health(ctx: Any) -> bool:
59
+ supervisor = ctx.app_server_supervisor
60
+ return supervisor is not None
61
+
62
+
63
+ def _check_opencode_health(ctx: Any) -> bool:
64
+ supervisor = ctx.opencode_supervisor
65
+ return supervisor is not None
66
+
67
+
68
+ _BUILTIN_AGENTS: dict[str, AgentDescriptor] = {
69
+ "codex": AgentDescriptor(
70
+ id="codex",
71
+ name="Codex",
72
+ capabilities=frozenset(
73
+ [
74
+ "threads",
75
+ "turns",
76
+ "review",
77
+ "model_listing",
78
+ "event_streaming",
79
+ "approvals",
80
+ ]
81
+ ),
82
+ make_harness=_make_codex_harness,
83
+ healthcheck=_check_codex_health,
84
+ ),
85
+ "opencode": AgentDescriptor(
86
+ id="opencode",
87
+ name="OpenCode",
88
+ capabilities=frozenset(
89
+ [
90
+ "threads",
91
+ "turns",
92
+ "review",
93
+ "model_listing",
94
+ "event_streaming",
95
+ ]
96
+ ),
97
+ make_harness=_make_opencode_harness,
98
+ healthcheck=_check_opencode_health,
99
+ ),
100
+ }
101
+
102
+ # Lazy-loaded cache of built-in + plugin agents.
103
+ _AGENT_CACHE: Optional[dict[str, AgentDescriptor]] = None
104
+
105
+
106
+ def _select_entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
107
+ """Compatibility wrapper for `importlib.metadata.entry_points()` across py versions."""
108
+
109
+ eps = importlib.metadata.entry_points()
110
+ # Python 3.9: may return a dict
111
+ if isinstance(eps, dict):
112
+ return eps.get(group, [])
113
+ if hasattr(eps, "select"):
114
+ return list(eps.select(group=group))
115
+ return []
116
+
117
+
118
+ def _load_agent_plugins() -> dict[str, AgentDescriptor]:
119
+ loaded: dict[str, AgentDescriptor] = {}
120
+ for ep in _select_entry_points(CAR_AGENT_ENTRYPOINT_GROUP):
121
+ try:
122
+ obj = ep.load()
123
+ except Exception as exc: # noqa: BLE001
124
+ _logger.warning(
125
+ "Failed to load agent plugin entry point %s:%s: %s",
126
+ ep.group,
127
+ ep.name,
128
+ exc,
129
+ )
130
+ continue
131
+
132
+ descriptor: Optional[AgentDescriptor] = None
133
+ if isinstance(obj, AgentDescriptor):
134
+ descriptor = obj
135
+ elif callable(obj):
136
+ try:
137
+ maybe = obj()
138
+ except Exception as exc: # noqa: BLE001
139
+ _logger.warning(
140
+ "Agent plugin entry point %s:%s factory failed: %s",
141
+ ep.group,
142
+ ep.name,
143
+ exc,
144
+ )
145
+ continue
146
+ if isinstance(maybe, AgentDescriptor):
147
+ descriptor = maybe
148
+
149
+ if descriptor is None:
150
+ _logger.warning(
151
+ "Ignoring agent plugin entry point %s:%s: expected AgentDescriptor or factory",
152
+ ep.group,
153
+ ep.name,
154
+ )
155
+ continue
156
+
157
+ agent_id = (descriptor.id or "").strip().lower()
158
+ if not agent_id:
159
+ _logger.warning(
160
+ "Ignoring agent plugin entry point %s:%s: missing id",
161
+ ep.group,
162
+ ep.name,
163
+ )
164
+ continue
165
+
166
+ if descriptor.plugin_api_version != CAR_PLUGIN_API_VERSION:
167
+ _logger.warning(
168
+ "Ignoring agent plugin %s (api_version=%s): expected %s",
169
+ agent_id,
170
+ descriptor.plugin_api_version,
171
+ CAR_PLUGIN_API_VERSION,
172
+ )
173
+ continue
174
+
175
+ if agent_id in _BUILTIN_AGENTS:
176
+ _logger.warning(
177
+ "Ignoring agent plugin %s: conflicts with built-in agent id",
178
+ agent_id,
179
+ )
180
+ continue
181
+ if agent_id in loaded:
182
+ _logger.warning(
183
+ "Ignoring duplicate agent plugin id %s from entry point %s:%s",
184
+ agent_id,
185
+ ep.group,
186
+ ep.name,
187
+ )
188
+ continue
189
+
190
+ loaded[agent_id] = descriptor
191
+ _logger.info("Loaded agent plugin: %s (%s)", agent_id, descriptor.name)
192
+
193
+ return loaded
194
+
195
+
196
+ def _all_agents() -> dict[str, AgentDescriptor]:
197
+ global _AGENT_CACHE
198
+ if _AGENT_CACHE is None:
199
+ agents = _BUILTIN_AGENTS.copy()
200
+ agents.update(_load_agent_plugins())
201
+ _AGENT_CACHE = agents
202
+ return _AGENT_CACHE
203
+
204
+
205
+ def reload_agents() -> dict[str, AgentDescriptor]:
206
+ """Clear the plugin cache and reload agent backends.
207
+
208
+ This is primarily useful for tests and local development.
209
+ """
210
+
211
+ global _AGENT_CACHE
212
+ _AGENT_CACHE = None
213
+ return get_registered_agents()
214
+
215
+
216
+ def get_registered_agents() -> dict[str, AgentDescriptor]:
217
+ return _all_agents().copy()
218
+
219
+
220
+ def get_available_agents(app_ctx: Any) -> dict[str, AgentDescriptor]:
221
+ available: dict[str, AgentDescriptor] = {}
222
+ for agent_id, descriptor in _all_agents().items():
223
+ if descriptor.healthcheck is None or descriptor.healthcheck(app_ctx):
224
+ available[agent_id] = descriptor
225
+ return available
226
+
227
+
228
+ def get_agent_descriptor(agent_id: str) -> Optional[AgentDescriptor]:
229
+ normalized = (agent_id or "").strip().lower()
230
+ return _all_agents().get(normalized)
231
+
232
+
233
+ def validate_agent_id(agent_id: str) -> str:
234
+ normalized = (agent_id or "").strip().lower()
235
+ if normalized not in _all_agents():
236
+ raise ValueError(f"Unknown agent: {agent_id!r}")
237
+ return normalized
238
+
239
+
240
+ def has_capability(agent_id: str, capability: AgentCapability) -> bool:
241
+ descriptor = get_agent_descriptor(agent_id)
242
+ if descriptor is None:
243
+ return False
244
+ return capability in descriptor.capabilities
245
+
246
+
247
+ __all__ = [
248
+ "AgentCapability",
249
+ "AgentDescriptor",
250
+ "CAR_PLUGIN_API_VERSION",
251
+ "CAR_AGENT_ENTRYPOINT_GROUP",
252
+ "get_registered_agents",
253
+ "get_available_agents",
254
+ "get_agent_descriptor",
255
+ "validate_agent_id",
256
+ "has_capability",
257
+ "reload_agents",
258
+ ]
@@ -1,10 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass
4
- from typing import Literal
4
+ from typing import NewType
5
5
 
6
6
  # When adding agents, update core/config.py agents defaults + validation (config-driven).
7
- AgentId = Literal["codex", "opencode"]
7
+ AgentId = NewType("AgentId", str)
8
8
 
9
9
 
10
10
  @dataclass(frozen=True)
@@ -0,0 +1,25 @@
1
+ """Stable public API for Codex Autorunner plugins.
2
+
3
+ Everything else in the codebase should be treated as internal unless documented.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .agents.base import AgentHarness
9
+ from .agents.registry import AgentCapability, AgentDescriptor, reload_agents
10
+ from .agents.types import AgentId, ConversationRef, ModelCatalog, ModelSpec, TurnRef
11
+ from .plugin_api import CAR_AGENT_ENTRYPOINT_GROUP, CAR_PLUGIN_API_VERSION
12
+
13
+ __all__ = [
14
+ "AgentCapability",
15
+ "AgentDescriptor",
16
+ "AgentHarness",
17
+ "AgentId",
18
+ "ConversationRef",
19
+ "ModelCatalog",
20
+ "ModelSpec",
21
+ "TurnRef",
22
+ "CAR_AGENT_ENTRYPOINT_GROUP",
23
+ "CAR_PLUGIN_API_VERSION",
24
+ "reload_agents",
25
+ ]