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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- 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
|