codex-autorunner 0.1.2__py3-none-any.whl → 1.1.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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -11,6 +11,7 @@ from typing import Any, Mapping, Optional, Sequence
11
11
  import httpx
12
12
 
13
13
  from ...core.logging_utils import log_event
14
+ from ...core.supervisor_utils import evict_lru_handle_locked, pop_idle_handles_locked
14
15
  from ...core.utils import infer_home_from_workspace, subprocess_env
15
16
  from ...workspace import canonical_workspace_root, workspace_id_for_path
16
17
  from .client import OpenCodeClient
@@ -53,20 +54,28 @@ class OpenCodeSupervisor:
53
54
  base_env: Optional[Mapping[str, str]] = None,
54
55
  base_url: Optional[str] = None,
55
56
  subagent_models: Optional[Mapping[str, str]] = None,
57
+ session_stall_timeout_seconds: Optional[float] = None,
56
58
  ) -> None:
57
59
  self._command = [str(arg) for arg in command]
58
60
  self._logger = logger or logging.getLogger(__name__)
59
61
  self._request_timeout = request_timeout
60
62
  self._max_handles = max_handles
61
63
  self._idle_ttl_seconds = idle_ttl_seconds
64
+ self._session_stall_timeout_seconds = session_stall_timeout_seconds
62
65
  if password and not username:
63
66
  username = "opencode"
64
- self._auth = (username, password) if password else None
67
+ self._auth: Optional[tuple[str, str]] = (
68
+ (username, password) if password and username else None
69
+ )
65
70
  self._base_env = base_env
66
71
  self._base_url = base_url
67
72
  self._subagent_models = subagent_models or {}
68
73
  self._handles: dict[str, OpenCodeHandle] = {}
69
- self._lock = asyncio.Lock()
74
+ self._lock: Optional[asyncio.Lock] = None
75
+
76
+ @property
77
+ def session_stall_timeout_seconds(self) -> Optional[float]:
78
+ return self._session_stall_timeout_seconds
70
79
 
71
80
  async def get_client(self, workspace_root: Path) -> OpenCodeClient:
72
81
  canonical_root = canonical_workspace_root(workspace_root)
@@ -79,7 +88,7 @@ class OpenCodeSupervisor:
79
88
  return handle.client
80
89
 
81
90
  async def close_all(self) -> None:
82
- async with self._lock:
91
+ async with self._get_lock():
83
92
  handles = list(self._handles.values())
84
93
  self._handles = {}
85
94
  for handle in handles:
@@ -98,7 +107,7 @@ class OpenCodeSupervisor:
98
107
  async def mark_turn_started(self, workspace_root: Path) -> None:
99
108
  canonical_root = canonical_workspace_root(workspace_root)
100
109
  workspace_id = workspace_id_for_path(canonical_root)
101
- async with self._lock:
110
+ async with self._get_lock():
102
111
  handle = self._handles.get(workspace_id)
103
112
  if handle is None:
104
113
  return
@@ -108,7 +117,7 @@ class OpenCodeSupervisor:
108
117
  async def mark_turn_finished(self, workspace_root: Path) -> None:
109
118
  canonical_root = canonical_workspace_root(workspace_root)
110
119
  workspace_id = workspace_id_for_path(canonical_root)
111
- async with self._lock:
120
+ async with self._get_lock():
112
121
  handle = self._handles.get(workspace_id)
113
122
  if handle is None:
114
123
  return
@@ -187,7 +196,7 @@ class OpenCodeSupervisor:
187
196
  ) -> OpenCodeHandle:
188
197
  handles_to_close: list[OpenCodeHandle] = []
189
198
  evicted_id: Optional[str] = None
190
- async with self._lock:
199
+ async with self._get_lock():
191
200
  existing = self._handles.get(workspace_id)
192
201
  if existing is not None:
193
202
  existing.last_used_at = time.monotonic()
@@ -287,7 +296,7 @@ class OpenCodeSupervisor:
287
296
  logging.WARNING,
288
297
  "opencode.openapi.fetch_failed",
289
298
  base_url=base_url,
290
- exc=str(exc),
299
+ exc=exc,
291
300
  )
292
301
  handle.openapi_spec = {}
293
302
  handle.started = True
@@ -356,7 +365,7 @@ class OpenCodeSupervisor:
356
365
  logging.WARNING,
357
366
  "opencode.openapi.fetch_failed",
358
367
  base_url=base_url,
359
- exc=str(exc),
368
+ exc=exc,
360
369
  )
361
370
  handle.openapi_spec = {}
362
371
  self._start_stdout_drain(handle)
@@ -468,53 +477,32 @@ class OpenCodeSupervisor:
468
477
  return match.group(1)
469
478
 
470
479
  async def _pop_idle_handles(self) -> list[OpenCodeHandle]:
471
- async with self._lock:
480
+ async with self._get_lock():
472
481
  return self._pop_idle_handles_locked()
473
482
 
483
+ def _get_lock(self) -> asyncio.Lock:
484
+ if self._lock is None:
485
+ self._lock = asyncio.Lock()
486
+ return self._lock
487
+
474
488
  def _pop_idle_handles_locked(self) -> list[OpenCodeHandle]:
475
- if not self._idle_ttl_seconds or self._idle_ttl_seconds <= 0:
476
- return []
477
- cutoff = time.monotonic() - self._idle_ttl_seconds
478
- stale: list[OpenCodeHandle] = []
479
- for handle in list(self._handles.values()):
480
- if handle.active_turns:
481
- log_event(
482
- self._logger,
483
- logging.INFO,
484
- "opencode.handle.prune.skipped",
485
- reason="active_turns",
486
- workspace_id=handle.workspace_id,
487
- workspace_root=str(handle.workspace_root),
488
- active_turns=handle.active_turns,
489
- )
490
- continue
491
- if handle.last_used_at and handle.last_used_at < cutoff:
492
- self._handles.pop(handle.workspace_id, None)
493
- stale.append(handle)
494
- return stale
489
+ return pop_idle_handles_locked(
490
+ self._handles,
491
+ self._idle_ttl_seconds,
492
+ self._logger,
493
+ "opencode",
494
+ last_used_at_getter=lambda h: h.last_used_at,
495
+ should_skip_prune=lambda h: h.active_turns > 0,
496
+ )
495
497
 
496
498
  def _evict_lru_handle_locked(self) -> Optional[OpenCodeHandle]:
497
- if not self._max_handles or self._max_handles <= 0:
498
- return None
499
- if len(self._handles) < self._max_handles:
500
- return None
501
- lru_handle = min(
502
- self._handles.values(),
503
- key=lambda handle: handle.last_used_at or 0.0,
504
- )
505
- log_event(
499
+ return evict_lru_handle_locked(
500
+ self._handles,
501
+ self._max_handles,
506
502
  self._logger,
507
- logging.INFO,
508
- "opencode.handle.evicted",
509
- reason="max_handles",
510
- workspace_id=lru_handle.workspace_id,
511
- workspace_root=str(lru_handle.workspace_root),
512
- max_handles=self._max_handles,
513
- handle_count=len(self._handles),
514
- last_used_at=lru_handle.last_used_at,
503
+ "opencode",
504
+ last_used_at_getter=lambda h: h.last_used_at or 0.0,
515
505
  )
516
- self._handles.pop(lru_handle.workspace_id, None)
517
- return lru_handle
518
506
 
519
507
 
520
508
  __all__ = ["OpenCodeHandle", "OpenCodeSupervisor", "OpenCodeSupervisorError"]
@@ -1,9 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import importlib.metadata
3
4
  import logging
4
5
  from dataclasses import dataclass
5
- from typing import Any, Callable, Literal, Optional
6
+ from typing import Any, Callable, Iterable, Literal, Optional
6
7
 
8
+ from ..plugin_api import CAR_AGENT_ENTRYPOINT_GROUP, CAR_PLUGIN_API_VERSION
7
9
  from .base import AgentHarness
8
10
  from .codex.harness import CodexHarness
9
11
  from .opencode.harness import OpenCodeHarness
@@ -22,11 +24,20 @@ AgentCapability = Literal[
22
24
 
23
25
  @dataclass(frozen=True)
24
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
+
25
35
  id: str
26
36
  name: str
27
37
  capabilities: frozenset[AgentCapability]
28
38
  make_harness: Callable[[Any], AgentHarness]
29
39
  healthcheck: Optional[Callable[[Any], bool]] = None
40
+ plugin_api_version: int = CAR_PLUGIN_API_VERSION
30
41
 
31
42
 
32
43
  def _make_codex_harness(ctx: Any) -> AgentHarness:
@@ -54,7 +65,7 @@ def _check_opencode_health(ctx: Any) -> bool:
54
65
  return supervisor is not None
55
66
 
56
67
 
57
- _REGISTERED_AGENTS: dict[str, AgentDescriptor] = {
68
+ _BUILTIN_AGENTS: dict[str, AgentDescriptor] = {
58
69
  "codex": AgentDescriptor(
59
70
  id="codex",
60
71
  name="Codex",
@@ -88,32 +99,165 @@ _REGISTERED_AGENTS: dict[str, AgentDescriptor] = {
88
99
  ),
89
100
  }
90
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
+ api_version_raw = getattr(descriptor, "plugin_api_version", None)
167
+ try:
168
+ api_version = int(api_version_raw)
169
+ except Exception:
170
+ api_version = None
171
+ if api_version is None:
172
+ _logger.warning(
173
+ "Ignoring agent plugin %s: invalid api_version %s",
174
+ agent_id,
175
+ api_version_raw,
176
+ )
177
+ continue
178
+ if api_version > CAR_PLUGIN_API_VERSION:
179
+ _logger.warning(
180
+ "Ignoring agent plugin %s (api_version=%s) requires newer core (%s)",
181
+ agent_id,
182
+ api_version,
183
+ CAR_PLUGIN_API_VERSION,
184
+ )
185
+ continue
186
+ if api_version < CAR_PLUGIN_API_VERSION:
187
+ _logger.info(
188
+ "Loaded agent plugin %s with older api_version=%s (current=%s)",
189
+ agent_id,
190
+ api_version,
191
+ CAR_PLUGIN_API_VERSION,
192
+ )
193
+
194
+ if agent_id in _BUILTIN_AGENTS:
195
+ _logger.warning(
196
+ "Ignoring agent plugin %s: conflicts with built-in agent id",
197
+ agent_id,
198
+ )
199
+ continue
200
+ if agent_id in loaded:
201
+ _logger.warning(
202
+ "Ignoring duplicate agent plugin id %s from entry point %s:%s",
203
+ agent_id,
204
+ ep.group,
205
+ ep.name,
206
+ )
207
+ continue
208
+
209
+ loaded[agent_id] = descriptor
210
+ _logger.info("Loaded agent plugin: %s (%s)", agent_id, descriptor.name)
211
+
212
+ return loaded
213
+
214
+
215
+ def _all_agents() -> dict[str, AgentDescriptor]:
216
+ global _AGENT_CACHE
217
+ if _AGENT_CACHE is None:
218
+ agents = _BUILTIN_AGENTS.copy()
219
+ agents.update(_load_agent_plugins())
220
+ _AGENT_CACHE = agents
221
+ return _AGENT_CACHE
222
+
223
+
224
+ def reload_agents() -> dict[str, AgentDescriptor]:
225
+ """Clear the plugin cache and reload agent backends.
226
+
227
+ This is primarily useful for tests and local development.
228
+ """
229
+
230
+ global _AGENT_CACHE
231
+ _AGENT_CACHE = None
232
+ return get_registered_agents()
233
+
91
234
 
92
235
  def get_registered_agents() -> dict[str, AgentDescriptor]:
93
- return _REGISTERED_AGENTS.copy()
236
+ return _all_agents().copy()
94
237
 
95
238
 
96
239
  def get_available_agents(app_ctx: Any) -> dict[str, AgentDescriptor]:
97
- available = {}
98
- for agent_id, descriptor in _REGISTERED_AGENTS.items():
240
+ available: dict[str, AgentDescriptor] = {}
241
+ for agent_id, descriptor in _all_agents().items():
99
242
  if descriptor.healthcheck is None or descriptor.healthcheck(app_ctx):
100
243
  available[agent_id] = descriptor
101
244
  return available
102
245
 
103
246
 
104
247
  def get_agent_descriptor(agent_id: str) -> Optional[AgentDescriptor]:
105
- return _REGISTERED_AGENTS.get(agent_id)
248
+ normalized = (agent_id or "").strip().lower()
249
+ return _all_agents().get(normalized)
106
250
 
107
251
 
108
252
  def validate_agent_id(agent_id: str) -> str:
109
253
  normalized = (agent_id or "").strip().lower()
110
- if normalized not in _REGISTERED_AGENTS:
254
+ if normalized not in _all_agents():
111
255
  raise ValueError(f"Unknown agent: {agent_id!r}")
112
256
  return normalized
113
257
 
114
258
 
115
259
  def has_capability(agent_id: str, capability: AgentCapability) -> bool:
116
- descriptor = _REGISTERED_AGENTS.get(agent_id)
260
+ descriptor = get_agent_descriptor(agent_id)
117
261
  if descriptor is None:
118
262
  return False
119
263
  return capability in descriptor.capabilities
@@ -122,9 +266,12 @@ def has_capability(agent_id: str, capability: AgentCapability) -> bool:
122
266
  __all__ = [
123
267
  "AgentCapability",
124
268
  "AgentDescriptor",
269
+ "CAR_PLUGIN_API_VERSION",
270
+ "CAR_AGENT_ENTRYPOINT_GROUP",
125
271
  "get_registered_agents",
126
272
  "get_available_agents",
127
273
  "get_agent_descriptor",
128
274
  "validate_agent_id",
129
275
  "has_capability",
276
+ "reload_agents",
130
277
  ]
@@ -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
+ ]
@@ -11,43 +11,21 @@ from .core.config import (
11
11
  resolve_hub_config_data,
12
12
  )
13
13
  from .core.state import RunnerState, save_state
14
+ from .core.ticket_linter_cli import ensure_ticket_linter
15
+ from .core.ticket_manager_cli import ensure_ticket_manager
14
16
  from .core.utils import atomic_write
15
17
  from .manifest import load_manifest
16
18
 
17
19
  GITIGNORE_CONTENT = "*"
20
+ GENERATED_CONFIG_HEADER = "# GENERATED by CAR - DO NOT EDIT\n"
18
21
 
19
22
 
20
23
  def sample_todo() -> str:
21
- return """# TODO\n\n- [ ] Replace this item with your first task\n- [ ] Add another task\n- [x] Example completed item\n"""
22
-
23
-
24
- def sample_opinions() -> str:
25
- return """# Opinions\n\n- Prefer small, well-tested changes.\n- Keep docs in sync with code.\n- Avoid unnecessary dependencies.\n"""
24
+ return ""
26
25
 
27
26
 
28
27
  def sample_spec() -> str:
29
- return """# Spec\n\n## Context\n- Add project background and goals here.\n\n## Requirements\n- Requirement 1\n- Requirement 2\n\n## Non-goals\n- Out of scope items\n"""
30
-
31
-
32
- def sample_summary() -> str:
33
- return """# Summary
34
-
35
- This doc is the **user-facing report and handoff** for work done by CAR agents.
36
-
37
- Use it for:
38
- - Anything that requires **user action** or an **external party** (not agents).
39
- - Unresolved decisions or blockers that agents can’t finish autonomously.
40
- - A final condensed report once TODO is complete.
41
-
42
- ## External/user actions
43
- - (none)
44
-
45
- ## Open questions / blockers
46
- - (none)
47
-
48
- ## Final report
49
- - (pending)
50
- """
28
+ return ""
51
29
 
52
30
 
53
31
  def _seed_doc(path: Path, force: bool, content: str) -> None:
@@ -82,6 +60,7 @@ def write_hub_config(hub_root: Path, force: bool = False) -> Path:
82
60
  return config_path
83
61
  config_path.parent.mkdir(parents=True, exist_ok=True)
84
62
  with config_path.open("w", encoding="utf-8") as f:
63
+ f.write(GENERATED_CONFIG_HEADER)
85
64
  yaml.safe_dump(
86
65
  resolve_hub_config_data(hub_root),
87
66
  f,
@@ -116,24 +95,30 @@ def seed_repo_files(
116
95
  if not log_path.exists() or force:
117
96
  log_path.write_text("", encoding="utf-8")
118
97
 
119
- _seed_doc(ca_dir / "TODO.md", force, sample_todo())
120
- _seed_doc(ca_dir / "PROGRESS.md", force, "# Progress\n\n")
121
- _seed_doc(ca_dir / "OPINIONS.md", force, sample_opinions())
122
- _seed_doc(ca_dir / "SPEC.md", force, sample_spec())
123
- _seed_doc(ca_dir / "SUMMARY.md", force, sample_summary())
98
+ tickets_dir = ca_dir / "tickets"
99
+ if not tickets_dir.exists() or force:
100
+ tickets_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ workspace_dir = ca_dir / "workspace"
103
+ if not workspace_dir.exists() or force:
104
+ workspace_dir.mkdir(parents=True, exist_ok=True)
105
+
106
+ _seed_doc(workspace_dir / "active_context.md", force, sample_todo())
107
+ _seed_doc(workspace_dir / "decisions.md", force, "")
108
+ _seed_doc(workspace_dir / "spec.md", force, sample_spec())
124
109
 
125
110
  # Seed an always-available briefing doc for interactive Codex sessions.
126
111
  ensure_about_car_file_for_repo(
127
112
  repo_root,
128
113
  doc_paths={
129
- "todo": ca_dir / "TODO.md",
130
- "progress": ca_dir / "PROGRESS.md",
131
- "opinions": ca_dir / "OPINIONS.md",
132
- "spec": ca_dir / "SPEC.md",
133
- "summary": ca_dir / "SUMMARY.md",
114
+ "active_context": workspace_dir / "active_context.md",
115
+ "decisions": workspace_dir / "decisions.md",
116
+ "spec": workspace_dir / "spec.md",
134
117
  },
135
118
  force=force,
136
119
  )
120
+ ensure_ticket_linter(repo_root, force=force)
121
+ ensure_ticket_manager(repo_root, force=force)
137
122
 
138
123
 
139
124
  def seed_hub_files(hub_root: Path, force: bool = False) -> None: