codeframe-ai 0.9.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 (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,62 @@
1
+ """Claude Code adapter for delegating task execution to the claude CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from codeframe.core.adapters.subprocess_adapter import SubprocessAdapter
8
+
9
+
10
+ class ClaudeCodeAdapter(SubprocessAdapter):
11
+ """Adapter that delegates code execution to Claude Code CLI.
12
+
13
+ Invokes ``claude`` with ``--print`` flag for non-interactive output.
14
+ The prompt is piped via stdin.
15
+
16
+ Requires the Claude Code CLI to be installed:
17
+ https://docs.anthropic.com/en/docs/claude-code
18
+ """
19
+
20
+ def __init__(self, allowlist: list[str] | None = None) -> None:
21
+ """Initialize the Claude Code adapter.
22
+
23
+ Args:
24
+ allowlist: Optional list of allowed tools/permissions.
25
+ If provided, uses ``--allowedTools`` flag for each tool.
26
+ When omitted, no permission flags are added (the caller
27
+ is responsible for configuring permissions externally).
28
+ """
29
+ cli_args = ["--print"]
30
+ if allowlist:
31
+ for tool in allowlist:
32
+ cli_args.extend(["--allowedTools", tool])
33
+
34
+ super().__init__(binary="claude", cli_args=cli_args)
35
+ self._allowlist = allowlist
36
+
37
+ @property
38
+ def name(self) -> str: # noqa: D102
39
+ return "claude-code"
40
+
41
+ def build_command(self, prompt: str, workspace_path: Path) -> list[str]:
42
+ """Build claude CLI command.
43
+
44
+ Args:
45
+ prompt: The task prompt (sent via stdin, not in the command).
46
+ workspace_path: Workspace root (cwd is set by the base class).
47
+
48
+ Returns:
49
+ Command list for subprocess.Popen.
50
+ """
51
+ return [self._binary_path, *self._cli_args]
52
+
53
+ def get_stdin(self, prompt: str) -> str | None:
54
+ """Send prompt via stdin.
55
+
56
+ Args:
57
+ prompt: The task prompt to pipe into the claude process.
58
+
59
+ Returns:
60
+ The prompt string.
61
+ """
62
+ return prompt
@@ -0,0 +1,393 @@
1
+ """Codex adapter using the app-server JSON-RPC protocol.
2
+
3
+ Speaks the JSON-RPC protocol that OpenAI's Codex app-server exposes over
4
+ stdio. Unlike the simple stdin-to-stdout adapters (Claude Code, OpenCode),
5
+ this adapter maintains a bidirectional conversation with the subprocess:
6
+
7
+ initialize -> initialized
8
+ thread/start
9
+ turn/start -> (stream of events) -> turn/completed | turn/failed
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import selectors
16
+ import shutil
17
+ import subprocess
18
+ import threading
19
+ import time
20
+ import uuid
21
+ from pathlib import Path
22
+ from typing import Any, Callable
23
+
24
+ from codeframe.core.adapters.agent_adapter import (
25
+ AdapterTokenUsage,
26
+ AgentEvent,
27
+ AgentResult,
28
+ )
29
+ from codeframe.core.adapters.git_utils import detect_modified_files
30
+
31
+
32
+ _TIMEOUT = object() # Sentinel for read timeout (distinct from EOF/None)
33
+
34
+
35
+ class CodexAdapter:
36
+ """Adapter that delegates code execution to OpenAI Codex via app-server protocol.
37
+
38
+ The Codex CLI is launched with ``app-server`` subcommand, producing a
39
+ JSON-RPC-over-stdio channel. The adapter performs a four-step handshake
40
+ then streams turn events until a terminal event arrives.
41
+ """
42
+
43
+ # Default timeouts
44
+ DEFAULT_TURN_TIMEOUT_MS = 3_600_000 # 1 hour
45
+ DEFAULT_READ_TIMEOUT_MS = 30_000 # 30 s per line
46
+ DEFAULT_STALL_TIMEOUT_MS = 300_000 # 5 min no-progress
47
+
48
+ def __init__(
49
+ self,
50
+ *,
51
+ codex_command: str = "codex",
52
+ approval_policy: str = "auto",
53
+ sandbox_mode: str | None = None,
54
+ turn_timeout_ms: int = DEFAULT_TURN_TIMEOUT_MS,
55
+ read_timeout_ms: int = DEFAULT_READ_TIMEOUT_MS,
56
+ stall_timeout_ms: int = DEFAULT_STALL_TIMEOUT_MS,
57
+ ) -> None:
58
+ self._binary = codex_command
59
+ self._approval_policy = approval_policy
60
+ self._sandbox_mode = sandbox_mode
61
+ self._turn_timeout_ms = turn_timeout_ms
62
+ self._read_timeout_ms = read_timeout_ms
63
+ self._stall_timeout_ms = stall_timeout_ms
64
+
65
+ resolved = shutil.which(codex_command)
66
+ if resolved is None:
67
+ raise EnvironmentError(
68
+ f"'{codex_command}' not found on PATH. "
69
+ f"Install it or ensure it is available in your environment."
70
+ )
71
+ self._binary_path = resolved
72
+
73
+ @property
74
+ def name(self) -> str:
75
+ return "codex"
76
+
77
+ @classmethod
78
+ def requirements(cls) -> dict[str, str]:
79
+ """Return required environment variables for ``cf engines check``."""
80
+ return {"OPENAI_API_KEY": "OpenAI API key"}
81
+
82
+ # ------------------------------------------------------------------
83
+ # AgentAdapter.run
84
+ # ------------------------------------------------------------------
85
+
86
+ def run(
87
+ self,
88
+ task_id: str,
89
+ prompt: str,
90
+ workspace_path: Path,
91
+ on_event: Callable[[AgentEvent], None] | None = None,
92
+ ) -> AgentResult:
93
+ """Execute a task via the Codex app-server protocol."""
94
+ start = time.monotonic()
95
+
96
+ try:
97
+ cmd = [self._binary_path, "app-server"]
98
+ process = subprocess.Popen(
99
+ cmd,
100
+ stdin=subprocess.PIPE,
101
+ stdout=subprocess.PIPE,
102
+ stderr=subprocess.PIPE,
103
+ cwd=str(workspace_path),
104
+ text=True,
105
+ )
106
+ except FileNotFoundError:
107
+ return AgentResult(
108
+ status="failed",
109
+ error=f"Binary '{self._binary}' not found during execution",
110
+ )
111
+ except OSError as e:
112
+ return AgentResult(
113
+ status="failed",
114
+ error=f"Failed to start '{self._binary}': {e}",
115
+ )
116
+
117
+ # Drain stderr in background to prevent deadlock
118
+ stderr_chunks: list[str] = []
119
+
120
+ def _drain_stderr() -> None:
121
+ if process.stderr:
122
+ stderr_chunks.append(process.stderr.read())
123
+
124
+ stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
125
+ stderr_thread.start()
126
+
127
+ try:
128
+ ok = self._handshake(
129
+ process.stdin, process.stdout, prompt=prompt, workspace_path=workspace_path
130
+ )
131
+ if not ok:
132
+ self._kill(process)
133
+ return AgentResult(
134
+ status="failed",
135
+ error="Codex app-server handshake failed (no initialized response)",
136
+ )
137
+
138
+ result = self._stream_turn(process.stdout, on_event=on_event, stdin=process.stdin)
139
+ except Exception as exc:
140
+ self._kill(process)
141
+ return AgentResult(status="failed", error=str(exc))
142
+ finally:
143
+ stderr_thread.join(timeout=5)
144
+ self._kill(process)
145
+
146
+ result.modified_files = self._detect_modified_files(workspace_path)
147
+ result.duration_ms = int((time.monotonic() - start) * 1000)
148
+ return result
149
+
150
+ # ------------------------------------------------------------------
151
+ # JSON-RPC framing
152
+ # ------------------------------------------------------------------
153
+
154
+ def _send(
155
+ self,
156
+ stdin: Any,
157
+ method: str,
158
+ params: dict,
159
+ msg_id: int | None = None,
160
+ ) -> None:
161
+ """Write a single JSON-RPC message to the subprocess stdin."""
162
+ msg: dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params}
163
+ if msg_id is not None:
164
+ msg["id"] = msg_id
165
+ stdin.write(json.dumps(msg) + "\n")
166
+ stdin.flush()
167
+
168
+ def _recv_line(self, stdout: Any, timeout_s: float) -> dict | object | None:
169
+ """Read one JSON-RPC line from stdout with enforced timeout.
170
+
171
+ Uses ``selectors`` to wait for data availability before reading,
172
+ preventing indefinite blocking if the subprocess stops writing.
173
+
174
+ Returns:
175
+ Parsed dict on success, ``_TIMEOUT`` sentinel on timeout (caller
176
+ should loop and re-check stall/turn timeouts), or ``None`` on EOF.
177
+ """
178
+ # Use selectors for real timeout enforcement on file-based stdout.
179
+ # Mock objects (in tests) won't have fileno(), so fall back to
180
+ # direct readline for those.
181
+ if hasattr(stdout, "fileno"):
182
+ sel = selectors.DefaultSelector()
183
+ try:
184
+ sel.register(stdout, selectors.EVENT_READ)
185
+ ready = sel.select(timeout=timeout_s)
186
+ finally:
187
+ sel.close()
188
+ if not ready:
189
+ return _TIMEOUT
190
+
191
+ line = stdout.readline()
192
+ if not line:
193
+ return None
194
+ try:
195
+ return json.loads(line)
196
+ except json.JSONDecodeError:
197
+ return None
198
+
199
+ # ------------------------------------------------------------------
200
+ # Handshake
201
+ # ------------------------------------------------------------------
202
+
203
+ def _handshake(
204
+ self,
205
+ stdin: Any,
206
+ stdout: Any,
207
+ *,
208
+ prompt: str,
209
+ workspace_path: Path,
210
+ ) -> bool:
211
+ """Perform the 4-step initialization handshake.
212
+
213
+ 1. Send ``initialize`` with capabilities
214
+ 2. Wait for ``initialized`` response
215
+ 3. Send ``thread/start``
216
+ 4. Send ``turn/start`` with the task prompt
217
+
218
+ Returns True on success, False on timeout/failure.
219
+ """
220
+ thread_id = str(uuid.uuid4())
221
+ turn_id = str(uuid.uuid4())
222
+
223
+ # Step 1: initialize
224
+ init_params: dict[str, Any] = {"capabilities": {}}
225
+ if self._sandbox_mode:
226
+ init_params["sandbox_mode"] = self._sandbox_mode
227
+ self._send(stdin, "initialize", init_params, msg_id=1)
228
+
229
+ # Step 2: wait for initialized
230
+ timeout_s = self._read_timeout_ms / 1000
231
+ response = self._recv_line(stdout, timeout_s=timeout_s)
232
+ if response is _TIMEOUT or response is None:
233
+ return False
234
+
235
+ # Accept either method="initialized" or a result response to id=1
236
+ method = response.get("method", "")
237
+ if method != "initialized" and "result" not in response:
238
+ return False
239
+
240
+ # Step 3: thread/start
241
+ self._send(stdin, "thread/start", {
242
+ "thread_id": thread_id,
243
+ "workspace": str(workspace_path),
244
+ })
245
+
246
+ # Step 4: turn/start
247
+ self._send(stdin, "turn/start", {
248
+ "turn_id": turn_id,
249
+ "prompt": prompt,
250
+ })
251
+
252
+ return True
253
+
254
+ # ------------------------------------------------------------------
255
+ # Turn streaming
256
+ # ------------------------------------------------------------------
257
+
258
+ def _stream_turn(
259
+ self,
260
+ stdout: Any,
261
+ *,
262
+ on_event: Callable[[AgentEvent], None] | None = None,
263
+ stdin: Any = None,
264
+ ) -> AgentResult:
265
+ """Stream turn events until a terminal event or timeout."""
266
+ last_event_time = time.monotonic()
267
+ turn_start = time.monotonic()
268
+ stall_timeout_s = self._stall_timeout_ms / 1000
269
+ turn_timeout_s = self._turn_timeout_ms / 1000
270
+ read_timeout_s = self._read_timeout_ms / 1000
271
+
272
+ while True:
273
+ # Check stall timeout
274
+ if stall_timeout_s > 0 and (time.monotonic() - last_event_time) > stall_timeout_s:
275
+ return AgentResult(
276
+ status="failed",
277
+ error=f"Stall timeout: no events for {self._stall_timeout_ms}ms",
278
+ )
279
+
280
+ # Check turn timeout
281
+ if turn_timeout_s > 0 and (time.monotonic() - turn_start) > turn_timeout_s:
282
+ return AgentResult(
283
+ status="failed",
284
+ error=f"Turn timeout: exceeded {self._turn_timeout_ms}ms",
285
+ )
286
+
287
+ msg = self._recv_line(stdout, timeout_s=read_timeout_s)
288
+ if msg is _TIMEOUT:
289
+ # Read timed out — loop back to check stall/turn timeouts
290
+ continue
291
+ if msg is None:
292
+ # EOF — process likely terminated
293
+ return AgentResult(
294
+ status="failed",
295
+ error="Process terminated unexpectedly (EOF)",
296
+ )
297
+
298
+ last_event_time = time.monotonic()
299
+ method = msg.get("method", "")
300
+ params = msg.get("params", {})
301
+
302
+ if method == "session_started":
303
+ if on_event:
304
+ on_event(AgentEvent(type="progress", message="Session started"))
305
+
306
+ elif method == "notification":
307
+ message = params.get("message", "")
308
+ if on_event:
309
+ on_event(AgentEvent(type="progress", message=message))
310
+
311
+ elif method == "tool_call":
312
+ if stdin:
313
+ self._handle_approval(stdin, msg, on_event=on_event)
314
+
315
+ elif method == "turn/completed":
316
+ input_t, output_t = self._extract_token_usage(msg)
317
+ return AgentResult(
318
+ status="completed",
319
+ token_usage=AdapterTokenUsage(
320
+ input_tokens=input_t,
321
+ output_tokens=output_t,
322
+ ),
323
+ )
324
+
325
+ elif method == "turn/failed":
326
+ error_msg = params.get("error", "Turn failed")
327
+ return AgentResult(status="failed", error=error_msg)
328
+
329
+ elif method == "turn/cancelled":
330
+ return AgentResult(status="failed", error="Turn cancelled by Codex")
331
+
332
+ # ------------------------------------------------------------------
333
+ # Approval handling
334
+ # ------------------------------------------------------------------
335
+
336
+ def _handle_approval(
337
+ self,
338
+ stdin: Any,
339
+ event: dict,
340
+ *,
341
+ on_event: Callable[[AgentEvent], None] | None = None,
342
+ ) -> None:
343
+ """Handle a tool_call event that requires approval."""
344
+ params = event.get("params", {})
345
+ tool_id = params.get("id", "unknown")
346
+ tool_name = params.get("name", "unknown")
347
+
348
+ if self._approval_policy == "auto":
349
+ self._send(stdin, "tool_call/approved", {"id": tool_id})
350
+ if on_event:
351
+ on_event(AgentEvent(
352
+ type="progress",
353
+ message=f"Auto-approved tool call: {tool_name}",
354
+ ))
355
+ else:
356
+ self._send(stdin, "tool_call/rejected", {"id": tool_id})
357
+ if on_event:
358
+ on_event(AgentEvent(
359
+ type="progress",
360
+ message=f"Rejected tool call (policy={self._approval_policy}): {tool_name}",
361
+ ))
362
+
363
+ # ------------------------------------------------------------------
364
+ # Token usage
365
+ # ------------------------------------------------------------------
366
+
367
+ def _extract_token_usage(self, event: dict) -> tuple[int, int]:
368
+ """Extract (input_tokens, output_tokens) from a turn_completed event."""
369
+ params = event.get("params", {})
370
+ usage = params.get("usage", {})
371
+ return (
372
+ usage.get("input_tokens", 0),
373
+ usage.get("output_tokens", 0),
374
+ )
375
+
376
+ # ------------------------------------------------------------------
377
+ # Helpers
378
+ # ------------------------------------------------------------------
379
+
380
+ @staticmethod
381
+ def _kill(process: subprocess.Popen) -> None:
382
+ """Terminate the subprocess if still running."""
383
+ if process.poll() is None:
384
+ process.kill()
385
+ try:
386
+ process.wait(timeout=5)
387
+ except subprocess.TimeoutExpired:
388
+ pass
389
+
390
+ @staticmethod
391
+ def _detect_modified_files(workspace_path: Path) -> list[str]:
392
+ """Detect files modified by the subprocess via git diff."""
393
+ return detect_modified_files(workspace_path)
@@ -0,0 +1,40 @@
1
+ """Shared git utilities for adapter file detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ def detect_modified_files(workspace_path: Path) -> list[str]:
10
+ """Detect files modified in a workspace via git diff.
11
+
12
+ Combines modified, staged, and untracked files. Returns an empty list
13
+ if git is unavailable or the workspace is not a git repo.
14
+ """
15
+ try:
16
+ result = subprocess.run(
17
+ ["git", "diff", "--name-only", "HEAD"],
18
+ cwd=str(workspace_path),
19
+ capture_output=True,
20
+ text=True,
21
+ timeout=10,
22
+ )
23
+ if result.returncode != 0:
24
+ return []
25
+
26
+ files = [f for f in result.stdout.strip().splitlines() if f]
27
+
28
+ untracked = subprocess.run(
29
+ ["git", "ls-files", "--others", "--exclude-standard"],
30
+ cwd=str(workspace_path),
31
+ capture_output=True,
32
+ text=True,
33
+ timeout=10,
34
+ )
35
+ if untracked.returncode == 0:
36
+ files.extend(f for f in untracked.stdout.strip().splitlines() if f)
37
+
38
+ return list(dict.fromkeys(files))
39
+ except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
40
+ return []
@@ -0,0 +1,126 @@
1
+ """Kilocode adapter for delegating task execution to the kilo CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ from pathlib import Path
9
+
10
+ from codeframe.core.adapters.agent_adapter import AgentResult
11
+ from codeframe.core.adapters.subprocess_adapter import SubprocessAdapter
12
+
13
+ # Exit code used by kilo when the timeout is exceeded
14
+ _KILO_TIMEOUT_EXIT_CODE = 124
15
+
16
+
17
+ class KilocodeAdapter(SubprocessAdapter):
18
+ """Adapter that delegates code execution to Kilocode CLI.
19
+
20
+ Invokes ``kilo run <prompt> --auto --workspace <path>`` for headless
21
+ non-interactive execution. The prompt is passed as a positional argument
22
+ (not via stdin), matching Kilocode's CLI interface.
23
+
24
+ Note on prompt length: the prompt is passed as a single positional argument.
25
+ Linux supports up to ~2 MB per argument, but macOS caps individual arguments
26
+ at 256 KB. Very large task contexts assembled by TaskContextPackager may fail
27
+ on macOS. If Kilocode adds stdin support in a future release, prefer that path.
28
+
29
+ Exit codes:
30
+ 0 — success
31
+ 124 — timeout exceeded (mirrors the standard ``timeout(1)`` convention)
32
+ * — execution error
33
+
34
+ Configuration via environment variables:
35
+ KILOCODE_PATH — path to kilo binary (default: "kilo", resolved from $PATH)
36
+ KILOCODE_MODEL — optional model override passed as ``--model``
37
+ KILOCODE_FLAGS — optional extra CLI flags (shell-quoted, e.g. ``--flag "val"``)
38
+
39
+ Requires Kilocode to be installed:
40
+ https://kilocode.ai/
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ *,
46
+ timeout_s: int | None = None,
47
+ ) -> None:
48
+ super().__init__(binary=self._resolve_binary(), timeout_s=timeout_s)
49
+
50
+ @property
51
+ def name(self) -> str: # noqa: D102
52
+ return "kilocode"
53
+
54
+ @staticmethod
55
+ def _resolve_binary() -> str:
56
+ """Return the kilo binary path from env or default."""
57
+ return os.environ.get("KILOCODE_PATH") or "kilo"
58
+
59
+ @classmethod
60
+ def requirements(cls) -> dict[str, str]:
61
+ """Return environment variables recognised by ``cf engines check``."""
62
+ return {
63
+ "KILOCODE_PATH": "Path to kilo binary (optional — defaults to 'kilo' on $PATH)",
64
+ }
65
+
66
+ @classmethod
67
+ def check_ready(cls) -> dict[str, bool]:
68
+ """Check if the kilo binary is available on PATH."""
69
+ return {"kilo_binary": shutil.which(cls._resolve_binary()) is not None}
70
+
71
+ def build_command(self, prompt: str, workspace_path: Path) -> list[str]:
72
+ """Build the kilo CLI command.
73
+
74
+ Kilocode takes the prompt as a positional argument, with ``--auto``
75
+ for non-interactive execution and ``--workspace`` for the repo root.
76
+
77
+ Args:
78
+ prompt: The task prompt passed as a positional argument.
79
+ workspace_path: Workspace root passed as ``--workspace``.
80
+
81
+ Returns:
82
+ Command list for subprocess.Popen.
83
+ """
84
+ cmd = [
85
+ self._binary_path,
86
+ "run",
87
+ prompt,
88
+ "--auto",
89
+ "--workspace",
90
+ str(workspace_path),
91
+ ]
92
+
93
+ model = os.environ.get("KILOCODE_MODEL")
94
+ if model:
95
+ cmd.extend(["--model", model])
96
+
97
+ extra_flags_str = os.environ.get("KILOCODE_FLAGS", "").strip()
98
+ if extra_flags_str:
99
+ cmd.extend(shlex.split(extra_flags_str))
100
+
101
+ return cmd
102
+
103
+ def get_stdin(self, prompt: str) -> str | None:
104
+ """Return None — prompt is passed as a positional CLI argument, not stdin."""
105
+ return None
106
+
107
+ def _map_result(
108
+ self,
109
+ exit_code: int,
110
+ stdout: str,
111
+ stderr: str,
112
+ workspace_path: Path,
113
+ ) -> AgentResult:
114
+ """Map kilo exit codes to AgentResult.
115
+
116
+ Exit code 124 indicates a timeout (kilo's standard timeout sentinel),
117
+ which is surfaced as a failed result with a descriptive message.
118
+ All other non-zero codes use the base class logic for blocker detection.
119
+ """
120
+ if exit_code == _KILO_TIMEOUT_EXIT_CODE:
121
+ return AgentResult(
122
+ status="failed",
123
+ output=stdout,
124
+ error="Kilocode execution timed out (exit code 124)",
125
+ )
126
+ return super()._map_result(exit_code, stdout, stderr, workspace_path)
@@ -0,0 +1,48 @@
1
+ """OpenCode adapter for delegating task execution to the opencode CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from codeframe.core.adapters.subprocess_adapter import SubprocessAdapter
8
+
9
+
10
+ class OpenCodeAdapter(SubprocessAdapter):
11
+ """Adapter that delegates code execution to OpenCode CLI.
12
+
13
+ Invokes ``opencode`` with ``--non-interactive`` flag for headless execution.
14
+ The prompt is piped via stdin.
15
+
16
+ Requires OpenCode to be installed:
17
+ https://github.com/opencode-ai/opencode
18
+ """
19
+
20
+ def __init__(self) -> None:
21
+ super().__init__(binary="opencode", cli_args=["--non-interactive"])
22
+
23
+ @property
24
+ def name(self) -> str: # noqa: D102
25
+ return "opencode"
26
+
27
+ def build_command(self, prompt: str, workspace_path: Path) -> list[str]:
28
+ """Build opencode CLI command.
29
+
30
+ Args:
31
+ prompt: The task prompt (sent via stdin, not in the command).
32
+ workspace_path: Workspace root (cwd is set by the base class).
33
+
34
+ Returns:
35
+ Command list for subprocess.Popen.
36
+ """
37
+ return [self._binary_path, *self._cli_args]
38
+
39
+ def get_stdin(self, prompt: str) -> str | None:
40
+ """Send prompt via stdin.
41
+
42
+ Args:
43
+ prompt: The task prompt to pipe into the opencode process.
44
+
45
+ Returns:
46
+ The prompt string.
47
+ """
48
+ return prompt