onecoder 0.0.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 (73) hide show
  1. onecoder/agent.py +95 -0
  2. onecoder/agentic_tool_search/__init__.py +0 -0
  3. onecoder/agentic_tool_search/dynamic_tool_search.py +64 -0
  4. onecoder/agentic_tool_search/registry.py +33 -0
  5. onecoder/agents/__init__.py +7 -0
  6. onecoder/agents/documentation_agent.py +12 -0
  7. onecoder/agents/file_reader_agent.py +19 -0
  8. onecoder/agents/file_writer_agent.py +19 -0
  9. onecoder/agents/orchestrator_agent.py +51 -0
  10. onecoder/agents/refactoring_agent.py +12 -0
  11. onecoder/agents/research_agent.py +31 -0
  12. onecoder/agents/task_suggestion_agent.py +88 -0
  13. onecoder/alignment.py +236 -0
  14. onecoder/api.py +162 -0
  15. onecoder/api_client.py +112 -0
  16. onecoder/backends/base.py +22 -0
  17. onecoder/backends/local_tui.py +65 -0
  18. onecoder/blackboard.py +102 -0
  19. onecoder/cli.py +108 -0
  20. onecoder/commands/__init__.py +1 -0
  21. onecoder/commands/auth.py +78 -0
  22. onecoder/commands/ci.py +29 -0
  23. onecoder/commands/delegate.py +557 -0
  24. onecoder/commands/doctor.py +40 -0
  25. onecoder/commands/issue.py +136 -0
  26. onecoder/commands/logs.py +45 -0
  27. onecoder/commands/project.py +270 -0
  28. onecoder/commands/server.py +170 -0
  29. onecoder/config_manager.py +87 -0
  30. onecoder/constants.py +9 -0
  31. onecoder/diagnostics/__init__.py +2 -0
  32. onecoder/diagnostics/env_scan.py +207 -0
  33. onecoder/discovery.py +101 -0
  34. onecoder/distillation.py +236 -0
  35. onecoder/evaluation/__init__.py +1 -0
  36. onecoder/evaluation/ttu.py +176 -0
  37. onecoder/governance/__init__.py +0 -0
  38. onecoder/governance/probllm.py +91 -0
  39. onecoder/hooks.py +74 -0
  40. onecoder/ipc_auth.py +200 -0
  41. onecoder/issues.py +188 -0
  42. onecoder/jules_client.py +343 -0
  43. onecoder/knowledge.py +106 -0
  44. onecoder/llm.py +61 -0
  45. onecoder/logger.py +42 -0
  46. onecoder/metrics.py +129 -0
  47. onecoder/models/delegation.py +46 -0
  48. onecoder/onboarding.py +264 -0
  49. onecoder/review.py +233 -0
  50. onecoder/services/delegation_service.py +209 -0
  51. onecoder/services/validation_service.py +104 -0
  52. onecoder/sessions.py +186 -0
  53. onecoder/sprint_collector.py +165 -0
  54. onecoder/sync.py +167 -0
  55. onecoder/tmux.py +86 -0
  56. onecoder/tools/__init__.py +10 -0
  57. onecoder/tools/executor.py +53 -0
  58. onecoder/tools/external_tools.py +106 -0
  59. onecoder/tools/file_tools.py +77 -0
  60. onecoder/tools/interface.py +25 -0
  61. onecoder/tools/jules_tools.py +122 -0
  62. onecoder/tools/kit_tools.py +122 -0
  63. onecoder/tools/registry.py +32 -0
  64. onecoder/tui/__init__.py +5 -0
  65. onecoder/tui/app.py +263 -0
  66. onecoder/tui/commands.py +150 -0
  67. onecoder/tui/widgets.py +92 -0
  68. onecoder/worktree.py +186 -0
  69. onecoder-0.0.2.dist-info/METADATA +17 -0
  70. onecoder-0.0.2.dist-info/RECORD +73 -0
  71. onecoder-0.0.2.dist-info/WHEEL +5 -0
  72. onecoder-0.0.2.dist-info/entry_points.txt +2 -0
  73. onecoder-0.0.2.dist-info/top_level.txt +1 -0
onecoder/alignment.py ADDED
@@ -0,0 +1,236 @@
1
+ import os
2
+ import json
3
+ import subprocess
4
+ import datetime
5
+ from pathlib import Path
6
+ from typing import List, Dict, Any, Optional
7
+ from .agent import get_model
8
+
9
+ class AlignmentTracker:
10
+ def __init__(self, repo_root: Optional[Path] = None):
11
+ self.repo_root = repo_root or self._find_repo_root()
12
+ self.alignment_dir = self.repo_root / ".onecoder" / "alignment"
13
+ self.logs_dir = self.alignment_dir / "logs"
14
+ self.logs_dir.mkdir(parents=True, exist_ok=True)
15
+
16
+ def _find_repo_root(self) -> Path:
17
+ current = Path.cwd().resolve()
18
+ while current != current.parent:
19
+ if (current / ".git").exists():
20
+ return current
21
+ current = current.parent
22
+ return Path.cwd().resolve()
23
+
24
+ def get_time_window(self, hour: int) -> str:
25
+ if 6 <= hour < 12:
26
+ return "Morning"
27
+ elif 12 <= hour < 18:
28
+ return "Afternoon"
29
+ elif 18 <= hour < 24:
30
+ return "Evening"
31
+ else:
32
+ return "Night"
33
+
34
+ def fetch_recent_prs(self, limit: int = 5) -> List[Dict[str, Any]]:
35
+ """Fetches recent merged PRs using gh CLI."""
36
+ try:
37
+ result = subprocess.run(
38
+ ["gh", "pr", "list", "--state", "merged", "--limit", str(limit), "--json", "number,title,mergedAt,author,url"],
39
+ capture_output=True,
40
+ text=True,
41
+ check=True
42
+ )
43
+ return json.loads(result.stdout)
44
+ except (subprocess.CalledProcessError, FileNotFoundError):
45
+ return []
46
+
47
+ def log_current_state(self) -> str:
48
+ """Logs the current commit and PR status to the filesystem."""
49
+ now = datetime.datetime.now()
50
+ date_str = now.strftime("%Y-%m-%d")
51
+ window = self.get_time_window(now.hour)
52
+
53
+ # Get latest commit
54
+ try:
55
+ commit_hash = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip()
56
+ commit_msg = subprocess.check_output(["git", "log", "-1", "--pretty=%B"], text=True).strip()
57
+ except subprocess.CalledProcessError:
58
+ commit_hash = "unknown"
59
+ commit_msg = "unknown"
60
+
61
+ recent_prs = self.fetch_recent_prs()
62
+
63
+ log_file = self.logs_dir / f"{date_str}.json"
64
+
65
+ if log_file.exists():
66
+ with open(log_file, "r") as f:
67
+ try:
68
+ daily_logs = json.load(f)
69
+ except json.JSONDecodeError:
70
+ daily_logs = {}
71
+ else:
72
+ daily_logs = {}
73
+
74
+ if window not in daily_logs:
75
+ daily_logs[window] = []
76
+
77
+ entry = {
78
+ "timestamp": now.isoformat(),
79
+ "commit_hash": commit_hash,
80
+ "commit_message": commit_msg,
81
+ "recent_prs": recent_prs
82
+ }
83
+
84
+ daily_logs[window].append(entry)
85
+
86
+ with open(log_file, "w") as f:
87
+ json.dump(daily_logs, f, indent=4)
88
+
89
+ return f"Logged state for {window} in {log_file.name}"
90
+
91
+ def check_roadmap_alignment(self) -> Dict[str, Any]:
92
+ """Checks current activity against MVP_ROADMAP.md."""
93
+ roadmap_path = self.repo_root / "MVP_ROADMAP.md"
94
+ if not roadmap_path.exists():
95
+ return {"status": "unknown", "message": "MVP_ROADMAP.md not found"}
96
+
97
+ roadmap_content = roadmap_path.read_text()
98
+
99
+ # Simple heuristic check: find active sections mentioned in recent commits
100
+ try:
101
+ recent_commits = subprocess.check_output(["git", "log", "-n", "10", "--pretty=%s"], text=True).split("\n")
102
+ except subprocess.CalledProcessError:
103
+ recent_commits = []
104
+
105
+ aligned_items = []
106
+ for line in roadmap_content.split("\n"):
107
+ if "###" in line or "- **" in line:
108
+ item = line.strip("#-* ").split(" (")[0]
109
+ for commit in recent_commits:
110
+ if item.lower() in commit.lower():
111
+ aligned_items.append(item)
112
+ break
113
+
114
+ return {
115
+ "status": "aligned" if aligned_items else "diverged",
116
+ "aligned_items": list(set(aligned_items)),
117
+ "message": f"Found {len(aligned_items)} items aligned with roadmap."
118
+ }
119
+
120
+ def capture_suggestions(self) -> List[str]:
121
+ """Scans ANTIGRAVITY.md and recent RETRO.md files for suggestions."""
122
+ suggestions = []
123
+
124
+ # Scan ANTIGRAVITY.md
125
+ antigravity_path = self.repo_root / "ANTIGRAVITY.md"
126
+ if antigravity_path.exists():
127
+ content = antigravity_path.read_text()
128
+ # Simple heuristic: lines in 'Future' or 'To Watch' sections
129
+ capture = False
130
+ for line in content.split("\n"):
131
+ if "##" in line and any(x in line for x in ["Future", "Watch", "Improvement"]):
132
+ capture = True
133
+ elif "##" in line:
134
+ capture = False
135
+
136
+ if capture and line.strip().startswith("-"):
137
+ suggestions.append(line.strip("- ").strip())
138
+
139
+ # Scan recent RETRO.md
140
+ sprint_dir = self.repo_root / ".sprint"
141
+ if sprint_dir.exists():
142
+ retro_files = sorted(list(sprint_dir.glob("*/RETRO.md")), key=os.path.getmtime, reverse=True)[:3]
143
+ for retro_path in retro_files:
144
+ content = retro_path.read_text()
145
+ # Simple heuristic: bullet points under 'To Improve' or 'Learnings'
146
+ capture = False
147
+ for line in content.split("\n"):
148
+ if "##" in line and any(x in line for x in ["Improve", "Learning", "Next"]):
149
+ capture = True
150
+ elif "##" in line:
151
+ capture = False
152
+
153
+ if capture and line.strip().startswith("-"):
154
+ suggestions.append(line.strip("- ").strip())
155
+
156
+ return list(set(suggestions))
157
+
158
+ def summarize_alignment_agentic(self, alignment_data: Dict[str, Any], recent_prs: List[Dict[str, Any]], suggestions: List[str]) -> str:
159
+ """Uses LLM to summarize the alignment status."""
160
+ try:
161
+ from google.adk.agents import LlmAgent
162
+ model = get_model()
163
+ agent = LlmAgent(
164
+ name="alignment_summarizer",
165
+ model=model,
166
+ instruction="You are a project manager analyst. Provide a 2-3 sentence summary of project health."
167
+ )
168
+
169
+ prompt = f"""
170
+ Analyze current alignment:
171
+ Roadmap Status: {alignment_data['status']}
172
+ Aligned Items: {alignment_data['aligned_items']}
173
+ Recent PRs: {json.dumps(recent_prs)}
174
+ Suggestions: {json.dumps(suggestions[:5])}
175
+ """
176
+
177
+ response = agent.run(prompt)
178
+ return response
179
+
180
+ except Exception as e:
181
+ return f"Agentic summary unavailable: {e}"
182
+
183
+
184
+ except Exception as e:
185
+ return {"status": "DRIFTING", "message": f"Semantic check fallback: {e}", "aligned_items": [], "drift_items": []}
186
+
187
+ def check_roadmap_alignment_agentic(self) -> Dict[str, Any]:
188
+ """Uses LLM to semantically compare activity against MVP_ROADMAP.md."""
189
+ roadmap_path = self.repo_root / "MVP_ROADMAP.md"
190
+ if not roadmap_path.exists():
191
+ return {"status": "unknown", "message": "MVP_ROADMAP.md not found"}
192
+
193
+ roadmap_content = roadmap_path.read_text()
194
+
195
+ try:
196
+ recent_commits = subprocess.check_output(["git", "log", "-n", "20", "--pretty=%s"], text=True)
197
+ recent_prs = self.fetch_recent_prs(limit=10)
198
+
199
+ model = get_model()
200
+
201
+ prompt = f"""
202
+ Analyze the following Roadmap and recent development activity (commits and PRs).
203
+ Perform a semantic mapping of the activity to the Roadmap items.
204
+
205
+ ROADMAP:
206
+ {roadmap_content}
207
+
208
+ RECENT COMMITS:
209
+ {recent_commits}
210
+
211
+ RECENT PRS:
212
+ {json.dumps(recent_prs)}
213
+
214
+ Categorize the project status as:
215
+ - **ALIGNED**: Work is directly advancing items in the Roadmap.
216
+ - **DRIFTING**: Work is valuable but not explicitly in the Roadmap.
217
+ - **DIVERGED**: Work is unrelated to the Roadmap goals.
218
+
219
+ Format your response as a JSON object:
220
+ {{
221
+ "status": "ALIGNED" | "DRIFTING" | "DIVERGED",
222
+ "aligned_items": ["List of items from the roadmap that the work aligns with"],
223
+ "drift_items": ["List of work items that are not in the roadmap"],
224
+ "message": "A brief explanation of the alignment status (1-2 sentences)"
225
+ }}
226
+ """
227
+
228
+ if hasattr(model, "completion"):
229
+ response = model.completion(messages=[{"role": "user", "content": prompt}], response_format={ "type": "json_object" })
230
+ data = json.loads(response.choices[0].message.content)
231
+ return data
232
+ else:
233
+ return {"status": "DRIFTING", "aligned_items": [], "drift_items": [], "message": "LiteLLM fallback. Run with GEMINI_API_KEY for semantic analysis."}
234
+
235
+ except Exception as e:
236
+ return {"status": "error", "message": f"Semantic check failed: {e}", "aligned_items": [], "drift_items": []}
onecoder/api.py ADDED
@@ -0,0 +1,162 @@
1
+ import json
2
+ import asyncio
3
+ import os
4
+ from typing import Any
5
+ from pathlib import Path
6
+ from fastapi import FastAPI, Request, HTTPException, Depends
7
+ from fastapi.responses import StreamingResponse, RedirectResponse
8
+ from fastapi.staticfiles import StaticFiles
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from google.adk.runners import Runner
11
+ from .sessions import DurableSessionService
12
+ from google.genai.types import Content, Part
13
+ from .ipc_auth import TOKEN_STORE
14
+ from .agent import get_root_agent
15
+ from .distillation import capture_engine
16
+
17
+ app = FastAPI(title="OneCoder Agent API")
18
+
19
+ # Add CORS middleware for local development
20
+ app.add_middleware(
21
+ CORSMiddleware,
22
+ allow_origins=["*"],
23
+ allow_credentials=True,
24
+ allow_methods=["*"],
25
+ allow_headers=["*"],
26
+ )
27
+
28
+ # Mount static files for web UI
29
+ static_dir = Path(__file__).parent / "static"
30
+ if static_dir.exists():
31
+ app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
32
+
33
+ # Setup ADK session service and runner
34
+ session_service = DurableSessionService()
35
+ runner = Runner(
36
+ session_service=session_service, agent=get_root_agent(), app_name="onecoder-unified-api"
37
+ )
38
+
39
+
40
+ @app.get("/")
41
+ async def root(token: str | None = None):
42
+ """Root endpoint - redirects to web UI if token provided."""
43
+ if token:
44
+ return RedirectResponse(url=f"/static/index.html?token={token}")
45
+ return {
46
+ "message": "OneCoder API",
47
+ "version": "0.1.0",
48
+ "endpoints": {
49
+ "web_ui": "/static/index.html?token=<token>",
50
+ "chat": "POST /chat",
51
+ "stream": "GET /stream",
52
+ },
53
+ }
54
+
55
+
56
+ async def verify_token(request: Request):
57
+ """
58
+ Middleware-like dependency to verify session-based tokens.
59
+ The token can be passed in 'Authorization: Bearer <token>' or '?token=<token>'.
60
+ """
61
+ token = request.query_params.get("token")
62
+ auth_header = request.headers.get("Authorization")
63
+
64
+ if auth_header and auth_header.startswith("Bearer "):
65
+ token = auth_header.split(" ")[1]
66
+
67
+ if not token or not TOKEN_STORE.validate_token(token):
68
+ raise HTTPException(
69
+ status_code=401, detail="Unauthorized: Invalid or expired token"
70
+ )
71
+
72
+
73
+ @app.post("/chat")
74
+ async def chat(user_id: str, session_id: str, message: str, _=Depends(verify_token)):
75
+ """
76
+ Standard synchronous chat endpoint (returns full response).
77
+ """
78
+ session = await session_service.get_session(
79
+ app_name="onecoder-unified-api", user_id=user_id, session_id=session_id
80
+ )
81
+ if not session:
82
+ session = await session_service.create_session(
83
+ app_name="onecoder-unified-api", user_id=user_id, session_id=session_id
84
+ )
85
+
86
+ final_text = ""
87
+ async for event in runner.run_async(
88
+ user_id=user_id,
89
+ session_id=session_id,
90
+ new_message=Content(parts=[Part(text=message)], role="user"),
91
+ ):
92
+ if hasattr(event, "content") and event.content and event.content.parts:
93
+ text_part = next((part for part in event.content.parts if part.text), None)
94
+ if text_part and text_part.text:
95
+ final_text += text_part.text
96
+
97
+ return {"response": final_text}
98
+
99
+
100
+ @app.get("/stream")
101
+ async def stream(user_id: str, session_id: str, message: str, token: str):
102
+ """
103
+ SSE endpoint for streaming agent responses and events.
104
+ FastAPI handles StreamingResponse well for this.
105
+ Note: Token validation is done inside since SSE doesn't always support headers easily.
106
+ """
107
+ if not TOKEN_STORE.validate_token(token):
108
+ raise HTTPException(status_code=401, detail="Unauthorized")
109
+
110
+ session = await session_service.get_session(
111
+ app_name="onecoder-unified-api", user_id=user_id, session_id=session_id
112
+ )
113
+ if not session:
114
+ session = await session_service.create_session(
115
+ app_name="onecoder-unified-api", user_id=user_id, session_id=session_id
116
+ )
117
+
118
+ async def event_generator():
119
+ capture_engine.start_session(session_id)
120
+ try:
121
+ async for event in runner.run_async(
122
+ user_id=user_id,
123
+ session_id=session_id,
124
+ new_message=Content(parts=[Part(text=message)], role="user"),
125
+ ):
126
+ # Format event for SSE
127
+ event_data: dict[str, str | dict[str, Any] | None] = {
128
+ "type": type(event).__name__,
129
+ }
130
+
131
+ if hasattr(event, "content") and event.content and event.content.parts:
132
+ text_part = next(
133
+ (part for part in event.content.parts if part.text), None
134
+ )
135
+ if text_part and text_part.text:
136
+ event_data["text"] = text_part.text
137
+
138
+ # Capture tool calls or delegation events if present
139
+ tool_call = next(
140
+ (part for part in event.content.parts if part.function_call),
141
+ None,
142
+ )
143
+ if tool_call and tool_call.function_call:
144
+ event_data["tool_call"] = {
145
+ "name": tool_call.function_call.name,
146
+ "args": tool_call.function_call.args,
147
+ }
148
+
149
+ capture_engine.log_event(event_data)
150
+ yield f"data: {json.dumps(event_data)}\n\n"
151
+
152
+ capture_engine.save_session()
153
+ except Exception as e:
154
+ yield f"data: {json.dumps({'type': 'Error', 'message': str(e)})}\n\n"
155
+
156
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
157
+
158
+
159
+ if __name__ == "__main__":
160
+ import uvicorn
161
+
162
+ uvicorn.run(app, host="127.0.0.1", port=8000)
onecoder/api_client.py ADDED
@@ -0,0 +1,112 @@
1
+ import httpx
2
+ import os
3
+ import json
4
+ from typing import Optional, Dict, Any, List
5
+
6
+ class OneCoderAPIClient:
7
+ def __init__(self, base_url: str, token: Optional[str] = None):
8
+ self.base_url = base_url.rstrip("/")
9
+ self.token = token
10
+ self.headers = {
11
+ "Content-Type": "application/json",
12
+ "Accept": "application/json",
13
+ }
14
+ if self.token:
15
+ self.headers["Authorization"] = f"Bearer {self.token}"
16
+
17
+ def set_token(self, token: str):
18
+ """Update the client's token."""
19
+ self.token = token
20
+ self.headers["Authorization"] = f"Bearer {token}"
21
+
22
+ async def _request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
23
+ """Internal helper for API requests."""
24
+ url = f"{self.base_url}/api/v1{path}"
25
+ if path.startswith("/api/v1"):
26
+ url = f"{self.base_url}{path}"
27
+
28
+ import logging
29
+ logging.debug(f"Making request to: {url}")
30
+
31
+ async with httpx.AsyncClient() as client:
32
+ response = await client.request(method, url, headers=self.headers, **kwargs)
33
+ response.raise_for_status()
34
+ return response.json()
35
+
36
+ async def login_with_github(self, code: str) -> Dict[str, Any]:
37
+ """Exchange GitHub OAuth code for a JWT token."""
38
+ data = await self._request("POST", "/auth/github", json={"code": code})
39
+ if "token" not in data:
40
+ raise ValueError(f"Invalid API response: Missing 'token' field. Got keys: {list(data.keys())}")
41
+ self.set_token(data["token"])
42
+ return data
43
+
44
+ async def get_preferences(self) -> Dict[str, Any]:
45
+ """Fetch user preferences from the API."""
46
+ return await self._request("GET", "/users/me/preferences")
47
+
48
+ async def update_preferences(self, preferences: Dict[str, Any]) -> Dict[str, Any]:
49
+ """Update user preferences via the API."""
50
+ return await self._request("PUT", "/users/me/preferences", json=preferences)
51
+
52
+ async def get_me(self) -> Dict[str, Any]:
53
+ """Get current user information."""
54
+ url = f"{self.base_url}/api/v1/users/me"
55
+ # Since /api/v1/users/me is not explicitly implemented in my last edit,
56
+ # I'll use /api/v1/auth/me or similar if I implement it, or just stick to what exists.
57
+ # Actually, let's implement /api/v1/users/me in the API later or use the token payload.
58
+ pass
59
+
60
+ async def sync_project(self, project_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
61
+ """Sync project durable context (specs, metadata) to the API."""
62
+ url = f"{self.base_url}/api/v1/projects/{project_id}/sync"
63
+ return await self._request("POST", f"/projects/{project_id}/sync", json=data)
64
+
65
+ async def sync_sprint(self, data: Dict[str, Any]) -> Dict[str, Any]:
66
+ """Sync sprint state (tasks, status) to the API."""
67
+ return await self._request("POST", "/sprints/sync", json=data)
68
+
69
+ async def sync_issues(self, data: Dict[str, Any]) -> Dict[str, Any]:
70
+ """Syncs issue data."""
71
+ return await self._request("POST", "/issues/sync", json=data)
72
+
73
+ async def get_workspaces(self) -> List[Dict[str, Any]]:
74
+ """Fetch list of available workspaces."""
75
+ data = await self._request("GET", "/workspaces")
76
+ return data.get("workspaces", [])
77
+
78
+ async def create_workspace(self, name: str) -> Dict[str, Any]:
79
+ """Create a new workspace."""
80
+ data = await self._request("POST", "/workspaces", json={"name": name})
81
+ return data.get("workspace", {})
82
+
83
+ async def create_project(self, name: str, workspace_id: Optional[str] = None) -> Dict[str, Any]:
84
+ """Create a new project."""
85
+ payload = {"name": name}
86
+ if workspace_id:
87
+ payload["workspaceId"] = workspace_id
88
+ data = await self._request("POST", "/projects", json=payload)
89
+ return data.get("project", {})
90
+
91
+ async def join_workspace(self, workspace_id: str, project_id: str) -> Dict[str, Any]:
92
+ """Associate a project with a workspace."""
93
+ return await self._request("POST", f"/workspaces/{workspace_id}/projects", json={"projectId": project_id})
94
+
95
+ async def upload_failure(self, failure_data: Dict[str, Any]) -> Dict[str, Any]:
96
+ """Upload a single failure mode to the API."""
97
+ return await self._request("POST", "/failure-modes", json=failure_data)
98
+
99
+ async def analyze_project(self, scan_data: Dict[str, Any], user_feedback: Optional[str] = None) -> Dict[str, Any]:
100
+ """Request server-side analysis of a project."""
101
+ payload = {"scanData": scan_data}
102
+ if user_feedback:
103
+ payload["userFeedback"] = user_feedback
104
+
105
+ # Increase timeout for analysis as it involves LLM processing
106
+ return await self._request("POST", "/projects/analyze", json=payload, timeout=60.0)
107
+
108
+ from .constants import ONECODER_API_URL
109
+
110
+ def get_api_client(token: Optional[str] = None) -> OneCoderAPIClient:
111
+ base_url = ONECODER_API_URL
112
+ return OneCoderAPIClient(base_url, token)
@@ -0,0 +1,22 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional
3
+ from ..models.delegation import DelegationSession
4
+
5
+ class BaseBackend(ABC):
6
+ """
7
+ Base class for delegation backends.
8
+ """
9
+
10
+ @abstractmethod
11
+ async def spawn(self, session: DelegationSession) -> str:
12
+ """
13
+ Spawns the delegated task and returns a connection string or identifier.
14
+ """
15
+ pass
16
+
17
+ @abstractmethod
18
+ async def cleanup(self, session: DelegationSession):
19
+ """
20
+ Cleans up resources associated with the session.
21
+ """
22
+ pass
@@ -0,0 +1,65 @@
1
+ import os
2
+ import logging
3
+ from pathlib import Path
4
+ from .base import BaseBackend
5
+ from ..models.delegation import DelegationSession
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from ..services.delegation_service import DelegationService
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class LocalTUIBackend(BaseBackend):
14
+ """
15
+ Spawns a local terminal UI (via tmux) in an isolated worktree.
16
+ """
17
+
18
+ def __init__(self, delegation_service: 'DelegationService'):
19
+ self.service = delegation_service
20
+
21
+ async def spawn(self, session: DelegationSession) -> str:
22
+ # Use delegation service to handle the heavy lifting
23
+ logger.info(f"Spawning local TUI for session {session.id}")
24
+
25
+ # 1. Create worktree
26
+ wt_path = self.service.worktree_mgr.create_worktree(session.id)
27
+
28
+ # 2. Reduce TTU: Inject context
29
+ self.service._inject_context(wt_path, session)
30
+
31
+ # 3. Define tmux session name
32
+ tmux_name = f"onecoder-{session.id[:8]}"
33
+
34
+ # 4. Create tmux session
35
+ # Use gemini as default command if available, fallback to shell
36
+ # We also pass the task prompt to a temporary file for the agent to read
37
+ agent_cmd = "gemini"
38
+
39
+ # Check if gemini is in path
40
+ import shutil
41
+ if not shutil.which("gemini"):
42
+ logger.warning("Gemini CLI not found in PATH, falling back to shell.")
43
+ agent_cmd = "$SHELL"
44
+
45
+ cmd = f"cd {wt_path} && export ACTIVE_SPRINT_ID={os.environ.get('ACTIVE_SPRINT_ID', '')} && {agent_cmd}"
46
+ if session.command:
47
+ # Provide the prompt to the agent via a dedicated instruction file
48
+ instruction_path = wt_path / "INSTRUCTIONS.md"
49
+ instruction_path.write_text(f"# Task\n{session.command}")
50
+ # In some agents, we can pass the prompt as an argument
51
+ if agent_cmd == "gemini":
52
+ cmd = f"cd {wt_path} && export ACTIVE_SPRINT_ID={os.environ.get('ACTIVE_SPRINT_ID', '')} && {agent_cmd} 'Review INSTRUCTIONS.md and execute the task.'"
53
+ else:
54
+ cmd = f"cd {wt_path} && echo '# {session.command}' >> BOOTSTRAP.md && {agent_cmd}"
55
+
56
+ self.service.tmux_mgr.create_session(tmux_name, cmd, cwd=str(wt_path))
57
+
58
+ # 5. Update session status
59
+ session.mark_running(tmux_session=tmux_name, worktree_path=str(wt_path))
60
+ self.service._save_session(session)
61
+
62
+ return f"tmux attach -t {tmux_name}"
63
+
64
+ async def cleanup(self, session: DelegationSession):
65
+ self.service.stop_session(session.id)