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,21 @@
1
+ """Agent harness abstractions."""
2
+
3
+ from .registry import (
4
+ AgentCapability,
5
+ AgentDescriptor,
6
+ get_agent_descriptor,
7
+ get_available_agents,
8
+ get_registered_agents,
9
+ has_capability,
10
+ validate_agent_id,
11
+ )
12
+
13
+ __all__ = [
14
+ "AgentCapability",
15
+ "AgentDescriptor",
16
+ "get_registered_agents",
17
+ "get_available_agents",
18
+ "get_agent_descriptor",
19
+ "validate_agent_id",
20
+ "has_capability",
21
+ ]
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, AsyncIterator, Optional, Protocol
5
+
6
+ from .types import AgentId, ConversationRef, ModelCatalog, TurnRef
7
+
8
+
9
+ class AgentHarness(Protocol):
10
+ agent_id: AgentId
11
+ display_name: str
12
+
13
+ async def ensure_ready(self, workspace_root: Path) -> None: ...
14
+
15
+ async def model_catalog(self, workspace_root: Path) -> ModelCatalog: ...
16
+
17
+ async def new_conversation(
18
+ self, workspace_root: Path, title: Optional[str] = None
19
+ ) -> ConversationRef: ...
20
+
21
+ async def list_conversations(
22
+ self, workspace_root: Path
23
+ ) -> list[ConversationRef]: ...
24
+
25
+ async def resume_conversation(
26
+ self, workspace_root: Path, conversation_id: str
27
+ ) -> ConversationRef: ...
28
+
29
+ async def start_turn(
30
+ self,
31
+ workspace_root: Path,
32
+ conversation_id: str,
33
+ prompt: str,
34
+ model: Optional[str],
35
+ reasoning: Optional[str],
36
+ *,
37
+ approval_mode: Optional[str],
38
+ sandbox_policy: Optional[Any],
39
+ ) -> TurnRef: ...
40
+
41
+ async def start_review(
42
+ self,
43
+ workspace_root: Path,
44
+ conversation_id: str,
45
+ prompt: str,
46
+ model: Optional[str],
47
+ reasoning: Optional[str],
48
+ *,
49
+ approval_mode: Optional[str],
50
+ sandbox_policy: Optional[Any],
51
+ ) -> TurnRef: ...
52
+
53
+ async def interrupt(
54
+ self, workspace_root: Path, conversation_id: str, turn_id: Optional[str]
55
+ ) -> None: ...
56
+
57
+ def stream_events(
58
+ self, workspace_root: Path, conversation_id: str, turn_id: str
59
+ ) -> AsyncIterator[str]: ...
60
+
61
+
62
+ __all__ = ["AgentHarness"]
@@ -0,0 +1,5 @@
1
+ """Codex harness adapter."""
2
+
3
+ from .harness import CodexHarness
4
+
5
+ __all__ = ["CodexHarness"]
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, AsyncIterator, Optional
5
+
6
+ from ...core.app_server_events import AppServerEventBuffer
7
+ from ...integrations.app_server.supervisor import WorkspaceAppServerSupervisor
8
+ from ..base import AgentHarness
9
+ from ..types import AgentId, ConversationRef, ModelCatalog, ModelSpec, TurnRef
10
+
11
+ _DEFAULT_REASONING_EFFORTS = ("none", "minimal", "low", "medium", "high", "xhigh")
12
+
13
+
14
+ def _coerce_entries(result: Any, keys: tuple[str, ...]) -> list[dict[str, Any]]:
15
+ if isinstance(result, list):
16
+ return [entry for entry in result if isinstance(entry, dict)]
17
+ if isinstance(result, dict):
18
+ for key in keys:
19
+ value = result.get(key)
20
+ if isinstance(value, list):
21
+ return [entry for entry in value if isinstance(entry, dict)]
22
+ return []
23
+
24
+
25
+ def _select_default_model(result: Any, entries: list[dict[str, Any]]) -> str:
26
+ if isinstance(result, dict):
27
+ for key in (
28
+ "defaultModel",
29
+ "default_model",
30
+ "default",
31
+ "model",
32
+ "modelId",
33
+ "model_id",
34
+ ):
35
+ value = result.get(key)
36
+ if isinstance(value, str) and value:
37
+ return value
38
+ config = result.get("config")
39
+ if isinstance(config, dict):
40
+ for key in ("defaultModel", "default_model", "model", "modelId"):
41
+ value = config.get(key)
42
+ if isinstance(value, str) and value:
43
+ return value
44
+ for entry in entries:
45
+ if entry.get("default") or entry.get("isDefault"):
46
+ model_id = entry.get("model") or entry.get("id")
47
+ if isinstance(model_id, str) and model_id:
48
+ return model_id
49
+ for entry in entries:
50
+ model_id = entry.get("model") or entry.get("id")
51
+ if isinstance(model_id, str) and model_id:
52
+ return model_id
53
+ return ""
54
+
55
+
56
+ def _coerce_reasoning_efforts(entry: dict[str, Any]) -> list[str]:
57
+ efforts_raw = entry.get("supportedReasoningEfforts")
58
+ efforts: list[str] = []
59
+ if isinstance(efforts_raw, list):
60
+ for effort in efforts_raw:
61
+ if isinstance(effort, dict):
62
+ value = effort.get("reasoningEffort")
63
+ if isinstance(value, str):
64
+ efforts.append(value)
65
+ elif isinstance(effort, str):
66
+ efforts.append(effort)
67
+ default_effort = entry.get("defaultReasoningEffort")
68
+ if isinstance(default_effort, str) and default_effort:
69
+ efforts.append(default_effort)
70
+ if not efforts:
71
+ efforts = list(_DEFAULT_REASONING_EFFORTS)
72
+ return list(dict.fromkeys(efforts))
73
+
74
+
75
+ class CodexHarness(AgentHarness):
76
+ agent_id: AgentId = AgentId("codex")
77
+ display_name = "Codex"
78
+
79
+ def __init__(
80
+ self,
81
+ supervisor: WorkspaceAppServerSupervisor,
82
+ events: AppServerEventBuffer,
83
+ ) -> None:
84
+ self._supervisor = supervisor
85
+ self._events = events
86
+
87
+ async def ensure_ready(self, workspace_root: Path) -> None:
88
+ await self._supervisor.get_client(workspace_root)
89
+
90
+ async def model_catalog(self, workspace_root: Path) -> ModelCatalog:
91
+ client = await self._supervisor.get_client(workspace_root)
92
+ result = await client.model_list()
93
+ entries = _coerce_entries(result, ("data", "models", "items", "results"))
94
+ models: list[ModelSpec] = []
95
+ for entry in entries:
96
+ model_id = entry.get("model") or entry.get("id")
97
+ if not isinstance(model_id, str) or not model_id:
98
+ continue
99
+ display_name = entry.get("displayName") or entry.get("name") or model_id
100
+ if not isinstance(display_name, str) or not display_name:
101
+ display_name = model_id
102
+ efforts = _coerce_reasoning_efforts(entry)
103
+ models.append(
104
+ ModelSpec(
105
+ id=model_id,
106
+ display_name=display_name,
107
+ supports_reasoning=bool(efforts),
108
+ reasoning_options=efforts,
109
+ )
110
+ )
111
+ default_model = _select_default_model(result, entries)
112
+ if not default_model and models:
113
+ default_model = models[0].id
114
+ return ModelCatalog(default_model=default_model, models=models)
115
+
116
+ async def new_conversation(
117
+ self, workspace_root: Path, title: Optional[str] = None
118
+ ) -> ConversationRef:
119
+ client = await self._supervisor.get_client(workspace_root)
120
+ result = await client.thread_start(str(workspace_root))
121
+ thread_id = result.get("id")
122
+ if not isinstance(thread_id, str) or not thread_id:
123
+ raise ValueError("Codex app-server did not return a thread id")
124
+ return ConversationRef(agent=self.agent_id, id=thread_id)
125
+
126
+ async def list_conversations(self, workspace_root: Path) -> list[ConversationRef]:
127
+ client = await self._supervisor.get_client(workspace_root)
128
+ result = await client.thread_list()
129
+ entries = _coerce_entries(result, ("threads", "data", "items", "results"))
130
+ conversations: list[ConversationRef] = []
131
+ for entry in entries:
132
+ thread_id = entry.get("id")
133
+ if isinstance(thread_id, str) and thread_id:
134
+ conversations.append(ConversationRef(agent=self.agent_id, id=thread_id))
135
+ return conversations
136
+
137
+ async def resume_conversation(
138
+ self, workspace_root: Path, conversation_id: str
139
+ ) -> ConversationRef:
140
+ client = await self._supervisor.get_client(workspace_root)
141
+ result = await client.thread_resume(conversation_id)
142
+ thread_id = result.get("id") or conversation_id
143
+ if not isinstance(thread_id, str) or not thread_id:
144
+ thread_id = conversation_id
145
+ return ConversationRef(agent=self.agent_id, id=thread_id)
146
+
147
+ async def start_turn(
148
+ self,
149
+ workspace_root: Path,
150
+ conversation_id: str,
151
+ prompt: str,
152
+ model: Optional[str],
153
+ reasoning: Optional[str],
154
+ *,
155
+ approval_mode: Optional[str],
156
+ sandbox_policy: Optional[Any],
157
+ ) -> TurnRef:
158
+ client = await self._supervisor.get_client(workspace_root)
159
+ turn_kwargs: dict[str, Any] = {}
160
+ if model:
161
+ turn_kwargs["model"] = model
162
+ if reasoning:
163
+ turn_kwargs["effort"] = reasoning
164
+ handle = await client.turn_start(
165
+ conversation_id,
166
+ prompt,
167
+ approval_policy=approval_mode,
168
+ sandbox_policy=sandbox_policy,
169
+ **turn_kwargs,
170
+ )
171
+ await self._events.register_turn(handle.thread_id, handle.turn_id)
172
+ return TurnRef(conversation_id=handle.thread_id, turn_id=handle.turn_id)
173
+
174
+ async def start_review(
175
+ self,
176
+ workspace_root: Path,
177
+ conversation_id: str,
178
+ prompt: str,
179
+ model: Optional[str],
180
+ reasoning: Optional[str],
181
+ *,
182
+ approval_mode: Optional[str],
183
+ sandbox_policy: Optional[Any],
184
+ ) -> TurnRef:
185
+ client = await self._supervisor.get_client(workspace_root)
186
+ review_kwargs: dict[str, Any] = {}
187
+ if model:
188
+ review_kwargs["model"] = model
189
+ if reasoning:
190
+ review_kwargs["effort"] = reasoning
191
+ instructions = (prompt or "").strip()
192
+ if instructions:
193
+ target = {"type": "custom", "instructions": instructions}
194
+ else:
195
+ target = {"type": "uncommittedChanges"}
196
+ handle = await client.review_start(
197
+ conversation_id,
198
+ target=target,
199
+ approval_policy=approval_mode,
200
+ sandbox_policy=sandbox_policy,
201
+ **review_kwargs,
202
+ )
203
+ await self._events.register_turn(handle.thread_id, handle.turn_id)
204
+ return TurnRef(conversation_id=handle.thread_id, turn_id=handle.turn_id)
205
+
206
+ async def interrupt(
207
+ self, workspace_root: Path, conversation_id: str, turn_id: Optional[str]
208
+ ) -> None:
209
+ if not turn_id:
210
+ return
211
+ client = await self._supervisor.get_client(workspace_root)
212
+ await client.turn_interrupt(turn_id, thread_id=conversation_id)
213
+
214
+ def stream_events(
215
+ self, workspace_root: Path, conversation_id: str, turn_id: str
216
+ ) -> AsyncIterator[str]:
217
+ return self._events.stream(conversation_id, turn_id)
218
+
219
+
220
+ __all__ = ["CodexHarness"]
@@ -0,0 +1,292 @@
1
+ """Centralized approval and sandbox policy mappings for Codex and OpenCode agents."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Optional, Union
5
+
6
+ # ========================================================================
7
+ # Approval Policies
8
+ # ========================================================================
9
+
10
+
11
+ class ApprovalPolicy:
12
+ """Canonical approval policy values for both Codex and OpenCode."""
13
+
14
+ NEVER = "never"
15
+ ON_FAILURE = "on-failure"
16
+ ON_REQUEST = "on-request"
17
+ UNTRUSTED = "untrusted"
18
+
19
+ ALL_VALUES = {NEVER, ON_FAILURE, ON_REQUEST, UNTRUSTED}
20
+
21
+
22
+ # ========================================================================
23
+ # Sandbox Policies (Codex)
24
+ # ========================================================================
25
+
26
+
27
+ class SandboxPolicy:
28
+ """Canonical sandbox policy values for Codex app-server."""
29
+
30
+ DANGER_FULL_ACCESS = "dangerFullAccess"
31
+ READ_ONLY = "readOnly"
32
+ WORKSPACE_WRITE = "workspaceWrite"
33
+ EXTERNAL_SANDBOX = "externalSandbox"
34
+
35
+ ALL_VALUES = {
36
+ DANGER_FULL_ACCESS,
37
+ READ_ONLY,
38
+ WORKSPACE_WRITE,
39
+ EXTERNAL_SANDBOX,
40
+ }
41
+
42
+
43
+ # ========================================================================
44
+ # Permission Policies (OpenCode)
45
+ # ========================================================================
46
+
47
+
48
+ class PermissionPolicy:
49
+ """Canonical permission policy values for OpenCode."""
50
+
51
+ ALLOW = "allow"
52
+ DENY = "deny"
53
+ ASK = "ask"
54
+
55
+ ALL_VALUES = {ALLOW, DENY, ASK}
56
+
57
+
58
+ # ========================================================================
59
+ # Data Classes
60
+ # ========================================================================
61
+
62
+
63
+ @dataclass
64
+ class SandboxPolicyConfig:
65
+ """Configuration for Codex sandbox policies."""
66
+
67
+ policy: Union[str, dict[str, Any]]
68
+ """Either a string policy type or full policy dict with type and options."""
69
+
70
+
71
+ @dataclass
72
+ class PolicyMapping:
73
+ """Unified policy mapping for both Codex and OpenCode agents."""
74
+
75
+ approval_policy: str
76
+ sandbox_policy: Union[str, dict[str, Any]]
77
+ permission_policy: Optional[str] = None
78
+
79
+
80
+ # ========================================================================
81
+ # Normalization Functions
82
+ # ========================================================================
83
+
84
+
85
+ def normalize_approval_policy(policy: Optional[str]) -> str:
86
+ """Normalize approval policy to canonical value.
87
+
88
+ Args:
89
+ policy: Approval policy string (case-insensitive, various aliases accepted).
90
+
91
+ Returns:
92
+ Canonical approval policy value.
93
+
94
+ Raises:
95
+ ValueError: If policy is not a recognized value.
96
+ """
97
+ if policy is None:
98
+ return ApprovalPolicy.NEVER
99
+
100
+ if not isinstance(policy, str):
101
+ raise ValueError(f"Invalid approval policy: {policy!r}")
102
+
103
+ normalized = policy.strip()
104
+ if not normalized:
105
+ raise ValueError(f"Invalid approval policy: {policy!r}")
106
+
107
+ normalized = normalized.lower()
108
+
109
+ # Aliases for never
110
+ if normalized in ("never", "no", "false", "0"):
111
+ return ApprovalPolicy.NEVER
112
+
113
+ # Aliases for on-failure
114
+ if normalized in (
115
+ "on-failure",
116
+ "on_failure",
117
+ "onfailure",
118
+ "fail",
119
+ "failure",
120
+ ):
121
+ return ApprovalPolicy.ON_FAILURE
122
+
123
+ # Aliases for on-request
124
+ if normalized in ("on-request", "on_request", "onrequest", "ask", "prompt"):
125
+ return ApprovalPolicy.ON_REQUEST
126
+
127
+ # Aliases for untrusted
128
+ if normalized in (
129
+ "untrusted",
130
+ "unlesstrusted",
131
+ "unless-trusted",
132
+ "unless trusted",
133
+ "auto",
134
+ ):
135
+ return ApprovalPolicy.UNTRUSTED
136
+
137
+ raise ValueError(
138
+ f"Invalid approval policy: {policy!r}. "
139
+ f"Valid values: {', '.join(sorted(ApprovalPolicy.ALL_VALUES))}"
140
+ )
141
+
142
+
143
+ def normalize_sandbox_policy(policy: Optional[Any]) -> Union[str, dict[str, Any]]:
144
+ """Normalize sandbox policy to canonical value.
145
+
146
+ Args:
147
+ policy: Sandbox policy (string or dict with 'type' field).
148
+
149
+ Returns:
150
+ Normalized sandbox policy as string or dict.
151
+ """
152
+ if policy is None:
153
+ return SandboxPolicy.DANGER_FULL_ACCESS
154
+
155
+ # If it's a dict, normalize the type field
156
+ if isinstance(policy, dict):
157
+ policy_value = policy.copy()
158
+ type_value = policy_value.get("type")
159
+ if isinstance(type_value, str):
160
+ policy_value["type"] = normalize_sandbox_policy_type(type_value)
161
+ return policy_value
162
+
163
+ # If it's a string, wrap in dict structure
164
+ if isinstance(policy, str):
165
+ normalized_type = normalize_sandbox_policy_type(policy)
166
+ return {"type": normalized_type}
167
+
168
+ # For other types, convert to string and wrap
169
+ return {"type": SandboxPolicy.DANGER_FULL_ACCESS}
170
+
171
+
172
+ def normalize_sandbox_policy_type(raw: str) -> str:
173
+ """Normalize sandbox policy type string to canonical value.
174
+
175
+ Args:
176
+ raw: Sandbox policy type string (case-insensitive).
177
+
178
+ Returns:
179
+ Canonical sandbox policy type.
180
+ """
181
+ if not raw:
182
+ return SandboxPolicy.DANGER_FULL_ACCESS
183
+
184
+ # Normalize case and remove special characters
185
+ import re
186
+
187
+ cleaned = re.sub(r"[^a-zA-Z0-9]+", "", raw.strip())
188
+ if not cleaned:
189
+ return SandboxPolicy.DANGER_FULL_ACCESS
190
+
191
+ canonical = _SANDBOX_POLICY_CANONICAL.get(cleaned.lower())
192
+ return canonical or raw.strip()
193
+
194
+
195
+ _SANDBOX_POLICY_CANONICAL = {
196
+ "dangerfullaccess": SandboxPolicy.DANGER_FULL_ACCESS,
197
+ "readonly": SandboxPolicy.READ_ONLY,
198
+ "workspacewrite": SandboxPolicy.WORKSPACE_WRITE,
199
+ "externalsandbox": SandboxPolicy.EXTERNAL_SANDBOX,
200
+ }
201
+
202
+
203
+ # ========================================================================
204
+ # Mapping Functions
205
+ # ========================================================================
206
+
207
+
208
+ def map_approval_to_permission(
209
+ approval_policy: Optional[str], *, default: str = PermissionPolicy.ALLOW
210
+ ) -> str:
211
+ """Map approval policy to OpenCode permission policy.
212
+
213
+ This maps Codex-style approval policies to OpenCode-style permission policies.
214
+
215
+ Args:
216
+ approval_policy: Codex approval policy.
217
+ default: Default permission if policy is None or unrecognized.
218
+
219
+ Returns:
220
+ OpenCode permission policy (allow/deny/ask).
221
+ """
222
+ if approval_policy is None:
223
+ return default
224
+
225
+ try:
226
+ normalized = normalize_approval_policy(approval_policy)
227
+ except ValueError:
228
+ # Invalid policy, return default
229
+ return default
230
+
231
+ # Direct matches
232
+ if normalized == ApprovalPolicy.NEVER:
233
+ return PermissionPolicy.ALLOW
234
+ if normalized == ApprovalPolicy.ON_FAILURE:
235
+ return PermissionPolicy.ASK
236
+ if normalized == ApprovalPolicy.ON_REQUEST:
237
+ return PermissionPolicy.ASK
238
+ if normalized == ApprovalPolicy.UNTRUSTED:
239
+ return PermissionPolicy.ASK
240
+
241
+ return default
242
+
243
+
244
+ def build_codex_sandbox_policy(
245
+ sandbox_mode: Optional[str],
246
+ *,
247
+ repo_root: Optional[Any] = None,
248
+ network_access: bool = False,
249
+ ) -> Union[str, dict[str, Any]]:
250
+ """Build Codex sandbox policy from mode string.
251
+
252
+ Args:
253
+ sandbox_mode: Sandbox mode string.
254
+ repo_root: Repository root path (for workspaceWrite policy).
255
+ network_access: Whether to allow network access (for workspaceWrite).
256
+
257
+ Returns:
258
+ Sandbox policy string or dict.
259
+ """
260
+ if not sandbox_mode:
261
+ return SandboxPolicy.DANGER_FULL_ACCESS
262
+
263
+ normalized_mode = normalize_sandbox_policy_type(sandbox_mode)
264
+
265
+ # workspaceWrite requires dict structure with writableRoots and networkAccess
266
+ if normalized_mode == SandboxPolicy.WORKSPACE_WRITE and repo_root is not None:
267
+ return {
268
+ "type": SandboxPolicy.WORKSPACE_WRITE,
269
+ "writableRoots": [str(repo_root)],
270
+ "networkAccess": network_access,
271
+ }
272
+
273
+ # Other modes can be simple strings
274
+ return normalized_mode
275
+
276
+
277
+ # ========================================================================
278
+ # Exports
279
+ # ========================================================================
280
+
281
+ __all__ = [
282
+ "ApprovalPolicy",
283
+ "SandboxPolicy",
284
+ "PermissionPolicy",
285
+ "SandboxPolicyConfig",
286
+ "PolicyMapping",
287
+ "normalize_approval_policy",
288
+ "normalize_sandbox_policy",
289
+ "normalize_sandbox_policy_type",
290
+ "map_approval_to_permission",
291
+ "build_codex_sandbox_policy",
292
+ ]
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Optional, cast
4
+
5
+ from ..core.app_server_events import AppServerEventBuffer
6
+ from ..integrations.app_server.supervisor import WorkspaceAppServerSupervisor
7
+ from .codex.harness import CodexHarness
8
+ from .opencode.harness import OpenCodeHarness
9
+ from .opencode.supervisor import OpenCodeSupervisor
10
+ from .orchestrator import AgentOrchestrator, CodexOrchestrator, OpenCodeOrchestrator
11
+ from .registry import get_agent_descriptor
12
+
13
+
14
+ def create_orchestrator(
15
+ agent_id: str,
16
+ codex_supervisor: Optional[WorkspaceAppServerSupervisor] = None,
17
+ codex_events: Optional[AppServerEventBuffer] = None,
18
+ opencode_supervisor: Optional[OpenCodeSupervisor] = None,
19
+ ) -> AgentOrchestrator:
20
+ descriptor = get_agent_descriptor(agent_id)
21
+ if descriptor is None:
22
+ raise ValueError(f"Unknown agent: {agent_id}")
23
+
24
+ class _AppContext:
25
+ def __init__(
26
+ self,
27
+ app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None,
28
+ app_server_events: Optional[AppServerEventBuffer] = None,
29
+ opencode_supervisor: Optional[OpenCodeSupervisor] = None,
30
+ ):
31
+ self.app_server_supervisor = app_server_supervisor
32
+ self.app_server_events = app_server_events
33
+ self.opencode_supervisor = opencode_supervisor
34
+
35
+ app_ctx = _AppContext(codex_supervisor, codex_events, opencode_supervisor)
36
+ harness = descriptor.make_harness(app_ctx)
37
+
38
+ if agent_id == "codex":
39
+ if not isinstance(harness, CodexHarness):
40
+ raise RuntimeError(f"Expected CodexHarness but got {type(harness)}")
41
+ return CodexOrchestrator(harness, cast(AppServerEventBuffer, codex_events))
42
+ elif agent_id == "opencode":
43
+ if not isinstance(harness, OpenCodeHarness):
44
+ raise RuntimeError(f"Expected OpenCodeHarness but got {type(harness)}")
45
+ return OpenCodeOrchestrator(harness)
46
+ else:
47
+ raise RuntimeError(f"No orchestrator implementation for agent: {agent_id}")
48
+
49
+
50
+ __all__ = [
51
+ "create_orchestrator",
52
+ ]
@@ -0,0 +1,18 @@
1
+ """OpenCode harness support."""
2
+
3
+ from .client import OpenCodeClient
4
+ from .events import SSEEvent, parse_sse_lines
5
+ from .harness import OpenCodeHarness
6
+ from .run_prompt import OpenCodeRunConfig, OpenCodeRunResult, run_opencode_prompt
7
+ from .supervisor import OpenCodeSupervisor
8
+
9
+ __all__ = [
10
+ "OpenCodeClient",
11
+ "OpenCodeHarness",
12
+ "OpenCodeRunConfig",
13
+ "OpenCodeRunResult",
14
+ "OpenCodeSupervisor",
15
+ "SSEEvent",
16
+ "parse_sse_lines",
17
+ "run_opencode_prompt",
18
+ ]