kivi-cli 2.0.6__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.
- kivi_cli-2.0.6.dist-info/METADATA +17 -0
- kivi_cli-2.0.6.dist-info/RECORD +47 -0
- kivi_cli-2.0.6.dist-info/WHEEL +5 -0
- kivi_cli-2.0.6.dist-info/entry_points.txt +2 -0
- kivi_cli-2.0.6.dist-info/top_level.txt +1 -0
- kiviai/__init__.py +0 -0
- kiviai/backend.py +1372 -0
- kiviai/index.html +6172 -0
- kiviai/services/__init__.py +0 -0
- kiviai/services/git_service.py +219 -0
- kiviai/services/monitor.py +97 -0
- kiviai/services/providers/__init__.py +0 -0
- kiviai/services/providers/base.py +131 -0
- kiviai/services/providers/claude.py +125 -0
- kiviai/services/providers/copilot.py +251 -0
- kiviai/services/providers/inhouse.py +130 -0
- kiviai/services/providers/kivi.py +108 -0
- kiviai/services/providers/model_costs.py +137 -0
- kiviai/services/providers/openai_agent.py +113 -0
- kiviai/services/providers/opencode.py +157 -0
- kiviai/services/providers/orchestrator_provider.py +163 -0
- kiviai/services/providers/registry.py +89 -0
- kiviai/services/providers/vllm.py +319 -0
- kiviai/services/scheduler.py +244 -0
- kiviai/services/tools/__init__.py +1 -0
- kiviai/services/tools/copilot_tools/__init__.py +92 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/agent_orchestration/tools.py +296 -0
- kiviai/services/tools/copilot_tools/bash_execution/__init__.py +20 -0
- kiviai/services/tools/copilot_tools/bash_execution/sessions.py +335 -0
- kiviai/services/tools/copilot_tools/code_search/__init__.py +10 -0
- kiviai/services/tools/copilot_tools/code_search/tools.py +137 -0
- kiviai/services/tools/copilot_tools/file_operations/__init__.py +12 -0
- kiviai/services/tools/copilot_tools/file_operations/tools.py +197 -0
- kiviai/services/tools/copilot_tools/markdown/__init__.py +41 -0
- kiviai/services/tools/copilot_tools/markdown/custom_markdownify.py +774 -0
- kiviai/services/tools/copilot_tools/markdown/mrkdwn_analysis.py +1155 -0
- kiviai/services/tools/copilot_tools/markdown/tools.py +357 -0
- kiviai/services/tools/copilot_tools/session_workflow/__init__.py +17 -0
- kiviai/services/tools/copilot_tools/session_workflow/tools.py +199 -0
- kiviai/services/tools/copilot_tools/web/__init__.py +37 -0
- kiviai/services/tools/copilot_tools/web/_fetcher.py +39 -0
- kiviai/services/tools/copilot_tools/web/_store.py +122 -0
- kiviai/services/tools/copilot_tools/web/fetch.py +20 -0
- kiviai/services/tools/copilot_tools/web/search.py +116 -0
- kiviai/services/tools/copilot_tools/web/store_tools.py +92 -0
- kiviai/services/tools/tool_manager.py +239 -0
kiviai/backend.py
ADDED
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CC-UI v2 Backend — Unified FastAPI gateway.
|
|
3
|
+
|
|
4
|
+
Integrates services:
|
|
5
|
+
- Provider registry (Claude Code, OpenCode, Copilot, Local/vLLM)
|
|
6
|
+
- Task management with unified provider interface
|
|
7
|
+
- Scheduler (cron/delayed/recurring jobs)
|
|
8
|
+
- Monitor (health, metrics, system info)
|
|
9
|
+
- Git service (branches, commits, diffs)
|
|
10
|
+
- Directory autocomplete
|
|
11
|
+
"""
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import sqlite3
|
|
17
|
+
import uuid
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
|
22
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
23
|
+
from fastapi.responses import FileResponse
|
|
24
|
+
from fastapi.staticfiles import StaticFiles
|
|
25
|
+
from pydantic import BaseModel
|
|
26
|
+
|
|
27
|
+
from kiviai.services.providers.base import ProviderConfig, ProviderEvent, EventType
|
|
28
|
+
from kiviai.services.providers.registry import get_provider, list_providers, health_check_all
|
|
29
|
+
from kiviai.services.scheduler import Scheduler
|
|
30
|
+
from kiviai.services.monitor import Monitor
|
|
31
|
+
from kiviai.services.git_service import GitService
|
|
32
|
+
|
|
33
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
|
34
|
+
log = logging.getLogger("kiviai")
|
|
35
|
+
|
|
36
|
+
app = FastAPI(title="Kivi AI v2", version="2.0.6")
|
|
37
|
+
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
|
38
|
+
|
|
39
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
40
|
+
|
|
41
|
+
# ── Database ─────────────────────────────────────────────────────────
|
|
42
|
+
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".kiviai")
|
|
43
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
44
|
+
DB_PATH = os.path.join(CONFIG_DIR, "tasks.db")
|
|
45
|
+
|
|
46
|
+
def _db():
|
|
47
|
+
return sqlite3.connect(DB_PATH)
|
|
48
|
+
|
|
49
|
+
def _init_db():
|
|
50
|
+
with _db() as con:
|
|
51
|
+
con.execute("""CREATE TABLE IF NOT EXISTS tasks (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
label TEXT, status TEXT, history TEXT,
|
|
54
|
+
session_id TEXT, cwd TEXT, mode TEXT, model TEXT,
|
|
55
|
+
prompt TEXT, created_at TEXT, finished_at TEXT,
|
|
56
|
+
total_cost REAL, usage TEXT, branch TEXT
|
|
57
|
+
)""")
|
|
58
|
+
for col, ctype in [("finished_at","TEXT"),("total_cost","REAL"),("usage","TEXT"),("branch","TEXT"),("advisor","TEXT"),("advisor_model","TEXT"),("deleted_at","TEXT")]:
|
|
59
|
+
try:
|
|
60
|
+
con.execute(f"ALTER TABLE tasks ADD COLUMN {col} {ctype}")
|
|
61
|
+
con.commit()
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
con.execute("""CREATE TABLE IF NOT EXISTS workspaces (
|
|
65
|
+
id TEXT PRIMARY KEY,
|
|
66
|
+
name TEXT NOT NULL,
|
|
67
|
+
path TEXT UNIQUE NOT NULL
|
|
68
|
+
)""")
|
|
69
|
+
|
|
70
|
+
_init_db()
|
|
71
|
+
|
|
72
|
+
# ── In-memory state ──────────────────────────────────────────────────
|
|
73
|
+
_tasks: dict[str, dict] = {}
|
|
74
|
+
_providers: dict[str, Any] = {} # active provider instances per task
|
|
75
|
+
|
|
76
|
+
# ── Services ─────────────────────────────────────────────────────────
|
|
77
|
+
monitor = Monitor()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _create_task_callback(prompt, model, mode, cwd):
|
|
81
|
+
"""Callback for scheduler to create tasks."""
|
|
82
|
+
task_id = str(uuid.uuid4())
|
|
83
|
+
label = prompt[:40].strip() + ("…" if len(prompt) > 40 else "")
|
|
84
|
+
task = {
|
|
85
|
+
"id": task_id, "label": label, "status": "running",
|
|
86
|
+
"history": [{"role": "user", "content": prompt}],
|
|
87
|
+
"session_id": None, "cwd": cwd or os.getcwd(),
|
|
88
|
+
"mode": mode, "model": model, "prompt": prompt,
|
|
89
|
+
"created_at": datetime.now().isoformat(), "_stop": False,
|
|
90
|
+
"total_cost": 0, "usage": {}, "branch": "",
|
|
91
|
+
}
|
|
92
|
+
_tasks[task_id] = task
|
|
93
|
+
_save(task)
|
|
94
|
+
asyncio.create_task(_run_task(task))
|
|
95
|
+
return task_id
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
scheduler = Scheduler(DB_PATH, task_callback=_create_task_callback)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ── Task persistence ─────────────────────────────────────────────────
|
|
102
|
+
def _save(task: dict):
|
|
103
|
+
if task.get("_deleted"):
|
|
104
|
+
return
|
|
105
|
+
with _db() as con:
|
|
106
|
+
con.execute("""INSERT OR REPLACE INTO tasks
|
|
107
|
+
(id, label, status, history, session_id, cwd, mode, model, prompt, created_at, finished_at, total_cost, usage, branch, advisor, advisor_model, deleted_at)
|
|
108
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
|
109
|
+
(task["id"], task["label"], task["status"],
|
|
110
|
+
json.dumps(task["history"]), task["session_id"],
|
|
111
|
+
task["cwd"], task["mode"], task["model"],
|
|
112
|
+
task["prompt"], task["created_at"], task.get("finished_at"),
|
|
113
|
+
task.get("total_cost", 0), json.dumps(task.get("usage", {})),
|
|
114
|
+
task.get("branch", ""), task.get("advisor", ""), task.get("advisor_model", ""),
|
|
115
|
+
task.get("deleted_at")))
|
|
116
|
+
|
|
117
|
+
def _load_all() -> dict[str, dict]:
|
|
118
|
+
with _db() as con:
|
|
119
|
+
rows = con.execute("SELECT * FROM tasks ORDER BY created_at DESC").fetchall()
|
|
120
|
+
result = {}
|
|
121
|
+
cols = ["id","label","status","history","session_id","cwd","mode","model","prompt","created_at","finished_at","total_cost","usage","branch","advisor","advisor_model","deleted_at"]
|
|
122
|
+
for r in rows:
|
|
123
|
+
t = {}
|
|
124
|
+
for i, col in enumerate(cols):
|
|
125
|
+
if i < len(r):
|
|
126
|
+
t[col] = r[i]
|
|
127
|
+
else:
|
|
128
|
+
t[col] = None
|
|
129
|
+
t["history"] = json.loads(t["history"] or "[]")
|
|
130
|
+
t["usage"] = json.loads(t["usage"] or "{}")
|
|
131
|
+
t["total_cost"] = t["total_cost"] or 0
|
|
132
|
+
t["branch"] = t.get("branch") or ""
|
|
133
|
+
t["advisor"] = t.get("advisor") or ""
|
|
134
|
+
t["advisor_model"] = t.get("advisor_model") or ""
|
|
135
|
+
t["_stop"] = False
|
|
136
|
+
if t["status"] == "running":
|
|
137
|
+
t["status"] = "stopped"
|
|
138
|
+
result[t["id"]] = t
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
_tasks = _load_all()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ── Unified task runner ──────────────────────────────────────────────
|
|
145
|
+
def _merge_usage(task: dict, usage: dict | None):
|
|
146
|
+
if not usage:
|
|
147
|
+
return
|
|
148
|
+
u = task.setdefault("usage", {})
|
|
149
|
+
for key in ("input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens"):
|
|
150
|
+
if key in usage:
|
|
151
|
+
u[key] = u.get(key, 0) + usage[key]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _run_task(task: dict):
|
|
155
|
+
"""Run a task using the unified provider interface."""
|
|
156
|
+
model_name = task["model"]
|
|
157
|
+
|
|
158
|
+
# Map agent IDs to provider names for routing
|
|
159
|
+
_AGENT_TO_PROVIDER = {
|
|
160
|
+
"claude-code": "claude",
|
|
161
|
+
"opencode": "opencode",
|
|
162
|
+
"copilot": "copilot",
|
|
163
|
+
"local": "vllm",
|
|
164
|
+
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Map short model names to current Claude model IDs.
|
|
168
|
+
# Claude Code's own --model flag resolves these aliases natively, but we
|
|
169
|
+
# pin explicit IDs here so the UI's cost/usage breakdown groups by a
|
|
170
|
+
# stable model string rather than whatever the CLI defaults to that week.
|
|
171
|
+
_MODEL_ALIASES = {
|
|
172
|
+
"sonnet": "claude-sonnet-5",
|
|
173
|
+
"opus": "claude-opus-4-8",
|
|
174
|
+
"haiku": "claude-haiku-4-5",
|
|
175
|
+
"fable": "claude-fable-5",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Resolve agent ID to provider name
|
|
179
|
+
provider_name = _AGENT_TO_PROVIDER.get(model_name, model_name)
|
|
180
|
+
|
|
181
|
+
if model_name in _MODEL_ALIASES:
|
|
182
|
+
task.setdefault("extra", {})["model_override"] = _MODEL_ALIASES[model_name]
|
|
183
|
+
|
|
184
|
+
# For "local" agent, inject vLLM defaults if not in extra
|
|
185
|
+
if model_name == "local":
|
|
186
|
+
extra = task.setdefault("extra", {})
|
|
187
|
+
if not extra.get("vllm_url"):
|
|
188
|
+
extra["vllm_url"] = "http://192.168.170.49:8000"
|
|
189
|
+
if "vllm_model" not in extra:
|
|
190
|
+
extra["vllm_model"] = ""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
log.info("_run_task starting: agent=%s provider=%s prompt=%s", model_name, provider_name, task["prompt"][:60])
|
|
194
|
+
try:
|
|
195
|
+
provider = get_provider(provider_name)
|
|
196
|
+
except ValueError as e:
|
|
197
|
+
log.error("_run_task: provider not found: %s", e)
|
|
198
|
+
task["history"].append({"role": "assistant", "content": f"❌ {e}"})
|
|
199
|
+
task["status"] = "error"
|
|
200
|
+
task["finished_at"] = datetime.now().isoformat()
|
|
201
|
+
_save(task)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
_providers[task["id"]] = provider
|
|
205
|
+
|
|
206
|
+
config = ProviderConfig(
|
|
207
|
+
model=task.get("extra", {}).get("selected_model") or task.get("extra", {}).get("vllm_model") or task.get("extra", {}).get("model_override") or "",
|
|
208
|
+
mode=task["mode"],
|
|
209
|
+
cwd=task["cwd"],
|
|
210
|
+
session_id=task["session_id"],
|
|
211
|
+
base_url=task.get("extra", {}).get("vllm_url") or task.get("extra", {}).get("base_url") or "",
|
|
212
|
+
api_key=task.get("extra", {}).get("vllm_key") or task.get("extra", {}).get("api_key") or "",
|
|
213
|
+
extra=task.get("extra", {}),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
history_snapshot = task["history"][:]
|
|
217
|
+
tool_calls: list[list] = [] # [title, content, status]
|
|
218
|
+
agent_groups: list[dict] = [] # agent group entries
|
|
219
|
+
text_buf = ""
|
|
220
|
+
|
|
221
|
+
def snapshot():
|
|
222
|
+
tools = [{"role": "assistant", "content": c, "metadata": {"title": t, "status": s}} for t, c, s in tool_calls]
|
|
223
|
+
cur = [{"role": "assistant", "content": text_buf}] if text_buf else []
|
|
224
|
+
return history_snapshot + agent_groups + tools + cur
|
|
225
|
+
|
|
226
|
+
def close_pending():
|
|
227
|
+
for tc in reversed(tool_calls):
|
|
228
|
+
if tc[2] == "pending":
|
|
229
|
+
tc[2] = "done"
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
async for event in provider.run(task["prompt"], config, task["history"]):
|
|
234
|
+
if task["_stop"]:
|
|
235
|
+
await provider.stop()
|
|
236
|
+
for tc in tool_calls:
|
|
237
|
+
tc[2] = "done"
|
|
238
|
+
task["history"] = snapshot() + [{"role": "assistant", "content": "⏹ *stopped*"}]
|
|
239
|
+
task["status"] = "stopped"
|
|
240
|
+
task["finished_at"] = datetime.now().isoformat()
|
|
241
|
+
_save(task)
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
if event.type == EventType.TEXT:
|
|
245
|
+
text_buf += event.content
|
|
246
|
+
task["history"] = snapshot()
|
|
247
|
+
|
|
248
|
+
elif event.type == EventType.TOOL_START:
|
|
249
|
+
close_pending()
|
|
250
|
+
title = event.metadata.get("title", "⚙ tool")
|
|
251
|
+
args = event.metadata.get("args", "")
|
|
252
|
+
tool_calls.append([title, f"```json\n{args}\n```" if args else "", "pending"])
|
|
253
|
+
task["history"] = snapshot()
|
|
254
|
+
|
|
255
|
+
elif event.type == EventType.TOOL_RESULT:
|
|
256
|
+
close_pending()
|
|
257
|
+
is_error = event.metadata.get("is_error", False)
|
|
258
|
+
icon = "❌" if is_error else "✓"
|
|
259
|
+
preview = event.content[:600] + ("…" if len(event.content) > 600 else "")
|
|
260
|
+
tool_calls.append([f"{icon} result", f"```\n{preview}\n```", "done"])
|
|
261
|
+
task["history"] = snapshot()
|
|
262
|
+
|
|
263
|
+
elif event.type == EventType.THINKING:
|
|
264
|
+
tool_calls.append(["💭 Thinking", event.content[:500], "done"])
|
|
265
|
+
task["history"] = snapshot()
|
|
266
|
+
|
|
267
|
+
elif event.type == EventType.AGENT_GROUP:
|
|
268
|
+
agent_id = event.metadata.get("agent_id", "")
|
|
269
|
+
found = False
|
|
270
|
+
for entry in agent_groups:
|
|
271
|
+
if entry.get("agent_id") == agent_id:
|
|
272
|
+
entry.update(event.metadata)
|
|
273
|
+
entry["role"] = "agent-group"
|
|
274
|
+
found = True
|
|
275
|
+
break
|
|
276
|
+
if not found:
|
|
277
|
+
entry = {"role": "agent-group"}
|
|
278
|
+
entry.update(event.metadata)
|
|
279
|
+
agent_groups.append(entry)
|
|
280
|
+
task["history"] = snapshot()
|
|
281
|
+
|
|
282
|
+
elif event.type == EventType.COST:
|
|
283
|
+
if event.metadata.get("session_id"):
|
|
284
|
+
task["session_id"] = event.metadata["session_id"]
|
|
285
|
+
if event.metadata.get("total_cost_usd"):
|
|
286
|
+
task["total_cost"] = task.get("total_cost", 0) + event.metadata["total_cost_usd"]
|
|
287
|
+
_merge_usage(task, event.metadata.get("usage"))
|
|
288
|
+
|
|
289
|
+
elif event.type == EventType.ERROR:
|
|
290
|
+
task["history"] = snapshot() + [{"role": "assistant", "content": f"❌ {event.content}"}]
|
|
291
|
+
task["status"] = "error"
|
|
292
|
+
task["finished_at"] = datetime.now().isoformat()
|
|
293
|
+
_save(task)
|
|
294
|
+
_providers.pop(task["id"], None)
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
elif event.type == EventType.DONE:
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
_save(task)
|
|
301
|
+
|
|
302
|
+
except Exception as e:
|
|
303
|
+
log.exception("_run_task exception: %s", e)
|
|
304
|
+
task["history"] = snapshot() + [{"role": "assistant", "content": f"❌ {e}"}]
|
|
305
|
+
task["status"] = "error"
|
|
306
|
+
task["finished_at"] = datetime.now().isoformat()
|
|
307
|
+
_save(task)
|
|
308
|
+
_providers.pop(task["id"], None)
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
for tc in tool_calls:
|
|
312
|
+
tc[2] = "done"
|
|
313
|
+
final = [{"role": "assistant", "content": text_buf}] if text_buf else []
|
|
314
|
+
if not tool_calls and not text_buf and not agent_groups:
|
|
315
|
+
final = [{"role": "assistant", "content": "*(no response)*"}]
|
|
316
|
+
log.info("_run_task done: text_buf=%s tools=%d agents=%d", repr(text_buf)[:80], len(tool_calls), len(agent_groups))
|
|
317
|
+
task["history"] = history_snapshot + agent_groups + [
|
|
318
|
+
{"role": "assistant", "content": c, "metadata": {"title": t, "status": s}}
|
|
319
|
+
for t, c, s in tool_calls
|
|
320
|
+
] + final
|
|
321
|
+
|
|
322
|
+
# ── Advisor review loop ──────────────────────────────────────────
|
|
323
|
+
advisor_name = task.get("advisor", "")
|
|
324
|
+
if advisor_name and text_buf and not task["_stop"]:
|
|
325
|
+
await _run_advisor_review(task, text_buf)
|
|
326
|
+
return # advisor sets status and saves
|
|
327
|
+
|
|
328
|
+
task["status"] = "done"
|
|
329
|
+
task["finished_at"] = datetime.now().isoformat()
|
|
330
|
+
_save(task)
|
|
331
|
+
_providers.pop(task["id"], None)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def _run_advisor_review(task: dict, worker_output: str, max_rounds: int = 2):
|
|
335
|
+
"""Run advisor review on worker output. Advisor reviews and optionally triggers worker refinement."""
|
|
336
|
+
advisor_name = task.get("advisor", "")
|
|
337
|
+
advisor_model = task.get("advisor_model", "")
|
|
338
|
+
log.info("Advisor review: advisor=%s model=%s", advisor_name, advisor_model)
|
|
339
|
+
|
|
340
|
+
try:
|
|
341
|
+
advisor_provider = get_provider(advisor_name)
|
|
342
|
+
except ValueError as e:
|
|
343
|
+
log.warning("Advisor provider not found: %s — skipping review", e)
|
|
344
|
+
task["status"] = "done"
|
|
345
|
+
task["finished_at"] = datetime.now().isoformat()
|
|
346
|
+
_save(task)
|
|
347
|
+
_providers.pop(task["id"], None)
|
|
348
|
+
return
|
|
349
|
+
|
|
350
|
+
advisor_config = ProviderConfig(
|
|
351
|
+
model=advisor_model,
|
|
352
|
+
mode="plan",
|
|
353
|
+
cwd=task["cwd"],
|
|
354
|
+
session_id=None,
|
|
355
|
+
base_url=task.get("extra", {}).get("base_url", ""),
|
|
356
|
+
api_key=task.get("extra", {}).get("api_key", ""),
|
|
357
|
+
extra=task.get("extra", {}),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
review_prompt = f"""You are an advisor reviewing work done by another AI agent.
|
|
361
|
+
|
|
362
|
+
**Original task:** {task['prompt']}
|
|
363
|
+
|
|
364
|
+
**Worker's output:**
|
|
365
|
+
{worker_output[:4000]}
|
|
366
|
+
|
|
367
|
+
Please review this output. Provide:
|
|
368
|
+
1. A brief assessment (is it correct, complete, well-structured?)
|
|
369
|
+
2. Any issues or improvements needed
|
|
370
|
+
3. A final verdict: APPROVE if the work is good enough, or REVISE if it needs changes.
|
|
371
|
+
|
|
372
|
+
Keep your review concise and actionable."""
|
|
373
|
+
|
|
374
|
+
# Add advisor marker to history
|
|
375
|
+
task["history"].append({
|
|
376
|
+
"role": "agent-group",
|
|
377
|
+
"agent_id": "advisor",
|
|
378
|
+
"agent_name": f"🧠 Advisor ({advisor_name})",
|
|
379
|
+
"status": "running",
|
|
380
|
+
"children": [],
|
|
381
|
+
})
|
|
382
|
+
_save(task)
|
|
383
|
+
|
|
384
|
+
# Find the advisor agent-group entry
|
|
385
|
+
advisor_entry = None
|
|
386
|
+
for entry in task["history"]:
|
|
387
|
+
if entry.get("role") == "agent-group" and entry.get("agent_id") == "advisor":
|
|
388
|
+
advisor_entry = entry
|
|
389
|
+
break
|
|
390
|
+
|
|
391
|
+
advisor_text = ""
|
|
392
|
+
try:
|
|
393
|
+
async for event in advisor_provider.run(review_prompt, advisor_config, []):
|
|
394
|
+
if task["_stop"]:
|
|
395
|
+
await advisor_provider.stop()
|
|
396
|
+
if advisor_entry:
|
|
397
|
+
advisor_entry["status"] = "stopped"
|
|
398
|
+
task["status"] = "stopped"
|
|
399
|
+
task["finished_at"] = datetime.now().isoformat()
|
|
400
|
+
_save(task)
|
|
401
|
+
return
|
|
402
|
+
|
|
403
|
+
if event.type == EventType.TEXT:
|
|
404
|
+
advisor_text += event.content
|
|
405
|
+
if advisor_entry:
|
|
406
|
+
advisor_entry["children"] = [{"role": "assistant", "content": advisor_text}]
|
|
407
|
+
_save(task)
|
|
408
|
+
elif event.type == EventType.COST:
|
|
409
|
+
if event.metadata.get("total_cost_usd"):
|
|
410
|
+
task["total_cost"] = task.get("total_cost", 0) + event.metadata["total_cost_usd"]
|
|
411
|
+
_merge_usage(task, event.metadata.get("usage"))
|
|
412
|
+
elif event.type == EventType.DONE:
|
|
413
|
+
break
|
|
414
|
+
|
|
415
|
+
except Exception as e:
|
|
416
|
+
log.exception("Advisor review failed: %s", e)
|
|
417
|
+
if advisor_entry:
|
|
418
|
+
advisor_entry["status"] = "error"
|
|
419
|
+
advisor_entry["children"] = [{"role": "assistant", "content": f"❌ Advisor error: {e}"}]
|
|
420
|
+
|
|
421
|
+
if advisor_entry:
|
|
422
|
+
advisor_entry["status"] = "done"
|
|
423
|
+
if not advisor_entry.get("children"):
|
|
424
|
+
advisor_entry["children"] = [{"role": "assistant", "content": advisor_text or "*(no review)*"}]
|
|
425
|
+
|
|
426
|
+
task["status"] = "done"
|
|
427
|
+
task["finished_at"] = datetime.now().isoformat()
|
|
428
|
+
_save(task)
|
|
429
|
+
_providers.pop(task["id"], None)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ── API Models ───────────────────────────────────────────────────────
|
|
433
|
+
class CreateTaskRequest(BaseModel):
|
|
434
|
+
prompt: str
|
|
435
|
+
model: str = "claude-code"
|
|
436
|
+
agent_id: str = "" # which agent to use (e.g. "claude-code", "chat")
|
|
437
|
+
mode: str = "bypassPermissions" # kept for backward compat
|
|
438
|
+
cwd: str = ""
|
|
439
|
+
session_id: str | None = None
|
|
440
|
+
branch: str = ""
|
|
441
|
+
extra: dict = {}
|
|
442
|
+
advisor: str = "" # deprecated — kept for backward compat
|
|
443
|
+
advisor_model: str = "" # deprecated — kept for backward compat
|
|
444
|
+
|
|
445
|
+
class SendMessageRequest(BaseModel):
|
|
446
|
+
prompt: str
|
|
447
|
+
|
|
448
|
+
class WorkspaceRequest(BaseModel):
|
|
449
|
+
path: str
|
|
450
|
+
name: str = ""
|
|
451
|
+
|
|
452
|
+
class GitStageRequest(BaseModel):
|
|
453
|
+
files: list[str]
|
|
454
|
+
|
|
455
|
+
class CreateJobRequest(BaseModel):
|
|
456
|
+
name: str
|
|
457
|
+
prompt: str
|
|
458
|
+
model: str = "claude-code"
|
|
459
|
+
mode: str = "bypassPermissions"
|
|
460
|
+
cwd: str = ""
|
|
461
|
+
schedule: str = ""
|
|
462
|
+
interval_seconds: int = 0
|
|
463
|
+
delay_seconds: int = 0
|
|
464
|
+
one_shot: bool = False
|
|
465
|
+
|
|
466
|
+
class GitBranchRequest(BaseModel):
|
|
467
|
+
name: str
|
|
468
|
+
checkout: bool = True
|
|
469
|
+
|
|
470
|
+
class GitCommitRequest(BaseModel):
|
|
471
|
+
message: str
|
|
472
|
+
add_all: bool = True
|
|
473
|
+
|
|
474
|
+
class GitPRCreateRequest(BaseModel):
|
|
475
|
+
title: str
|
|
476
|
+
body: str = ""
|
|
477
|
+
base: str = "main"
|
|
478
|
+
head: str = ""
|
|
479
|
+
|
|
480
|
+
class GitPRMergeRequest(BaseModel):
|
|
481
|
+
pr_number: int
|
|
482
|
+
method: str = "merge" # merge, squash, rebase
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ── Routes: Static ───────────────────────────────────────────────────
|
|
486
|
+
@app.get("/")
|
|
487
|
+
async def index():
|
|
488
|
+
return FileResponse(os.path.join(HERE, "index.html"))
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ── Routes: Tasks ────────────────────────────────────────────────────
|
|
492
|
+
@app.post("/tasks")
|
|
493
|
+
async def create_task(req: CreateTaskRequest):
|
|
494
|
+
task_id = str(uuid.uuid4())
|
|
495
|
+
label = req.prompt[:40].strip() + ("…" if len(req.prompt) > 40 else "")
|
|
496
|
+
|
|
497
|
+
# Handle branch creation
|
|
498
|
+
branch = req.branch
|
|
499
|
+
if branch and req.cwd:
|
|
500
|
+
result = await GitService.create_branch(req.cwd, branch)
|
|
501
|
+
if not result.get("success"):
|
|
502
|
+
# Try switching instead
|
|
503
|
+
await GitService.switch_branch(req.cwd, branch)
|
|
504
|
+
|
|
505
|
+
task = {
|
|
506
|
+
"id": task_id, "label": label, "status": "running",
|
|
507
|
+
"history": [{"role": "user", "content": req.prompt}],
|
|
508
|
+
"session_id": req.session_id, "cwd": req.cwd or os.getcwd(),
|
|
509
|
+
"mode": req.mode, "model": req.model, "prompt": req.prompt,
|
|
510
|
+
"created_at": datetime.now().isoformat(), "_stop": False,
|
|
511
|
+
"total_cost": 0, "usage": {}, "branch": branch,
|
|
512
|
+
"extra": req.extra,
|
|
513
|
+
"advisor": req.advisor, "advisor_model": req.advisor_model,
|
|
514
|
+
}
|
|
515
|
+
_tasks[task_id] = task
|
|
516
|
+
_save(task)
|
|
517
|
+
asyncio.create_task(_run_task(task))
|
|
518
|
+
return {"id": task_id, "label": label, "status": "running"}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@app.get("/tasks")
|
|
522
|
+
async def list_tasks_route():
|
|
523
|
+
monitor.update_task_stats(_tasks)
|
|
524
|
+
result = []
|
|
525
|
+
for t in sorted(_tasks.values(), key=lambda x: x["created_at"], reverse=True):
|
|
526
|
+
if t.get("deleted_at"):
|
|
527
|
+
continue
|
|
528
|
+
last = ""
|
|
529
|
+
for msg in reversed(t["history"]):
|
|
530
|
+
if msg.get("role") == "assistant" and not msg.get("metadata"):
|
|
531
|
+
last = str(msg.get("content", ""))[:60]
|
|
532
|
+
break
|
|
533
|
+
result.append({
|
|
534
|
+
"id": t["id"], "label": t["label"], "status": t["status"],
|
|
535
|
+
"created_at": t["created_at"], "finished_at": t.get("finished_at"),
|
|
536
|
+
"preview": last, "cwd": t["cwd"], "model": t.get("model"),
|
|
537
|
+
"mode": t.get("mode"), "total_cost": t.get("total_cost", 0),
|
|
538
|
+
"usage": t.get("usage", {}), "branch": t.get("branch", ""),
|
|
539
|
+
"advisor": t.get("advisor", ""), "advisor_model": t.get("advisor_model", ""),
|
|
540
|
+
})
|
|
541
|
+
return result
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@app.get("/tasks/{task_id}")
|
|
545
|
+
async def get_task(task_id: str):
|
|
546
|
+
task = _tasks.get(task_id)
|
|
547
|
+
if not task:
|
|
548
|
+
raise HTTPException(404, "Task not found")
|
|
549
|
+
return {
|
|
550
|
+
"id": task["id"], "label": task["label"], "status": task["status"],
|
|
551
|
+
"history": task["history"], "session_id": task["session_id"],
|
|
552
|
+
"cwd": task["cwd"], "created_at": task["created_at"],
|
|
553
|
+
"total_cost": task.get("total_cost", 0),
|
|
554
|
+
"usage": task.get("usage", {}), "branch": task.get("branch", ""),
|
|
555
|
+
"advisor": task.get("advisor", ""), "advisor_model": task.get("advisor_model", ""),
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@app.post("/tasks/{task_id}/message")
|
|
560
|
+
async def send_message(task_id: str, req: SendMessageRequest):
|
|
561
|
+
task = _tasks.get(task_id)
|
|
562
|
+
if not task:
|
|
563
|
+
raise HTTPException(404, "Task not found")
|
|
564
|
+
if task["status"] == "running":
|
|
565
|
+
raise HTTPException(409, "Task is still running")
|
|
566
|
+
|
|
567
|
+
task["history"] = task["history"] + [{"role": "user", "content": req.prompt}]
|
|
568
|
+
task["prompt"] = req.prompt
|
|
569
|
+
task["status"] = "running"
|
|
570
|
+
task["_stop"] = False
|
|
571
|
+
asyncio.create_task(_run_task(task))
|
|
572
|
+
return {"id": task_id, "status": "running"}
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@app.post("/tasks/{task_id}/stop")
|
|
576
|
+
async def stop_task(task_id: str):
|
|
577
|
+
task = _tasks.get(task_id)
|
|
578
|
+
if not task:
|
|
579
|
+
raise HTTPException(404, "Task not found")
|
|
580
|
+
task["_stop"] = True
|
|
581
|
+
provider = _providers.get(task_id)
|
|
582
|
+
if provider:
|
|
583
|
+
await provider.stop()
|
|
584
|
+
return {"status": "stopping"}
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@app.post("/tasks/{task_id}/resume")
|
|
588
|
+
async def resume_task(task_id: str):
|
|
589
|
+
task = _tasks.get(task_id)
|
|
590
|
+
if not task:
|
|
591
|
+
raise HTTPException(404, "Task not found")
|
|
592
|
+
if task["status"] == "running":
|
|
593
|
+
raise HTTPException(409, "Task is already running")
|
|
594
|
+
resume_prompt = "Continue from where you stopped."
|
|
595
|
+
task["history"].append({"role": "user", "content": resume_prompt})
|
|
596
|
+
task["prompt"] = resume_prompt
|
|
597
|
+
task["status"] = "running"
|
|
598
|
+
task["_stop"] = False
|
|
599
|
+
asyncio.create_task(_run_task(task))
|
|
600
|
+
return {"id": task_id, "status": "running"}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@app.post("/tasks/{task_id}/fork")
|
|
604
|
+
async def fork_task(task_id: str):
|
|
605
|
+
"""Fork a task: clone history into a new task with a new session."""
|
|
606
|
+
original = _tasks.get(task_id)
|
|
607
|
+
if not original:
|
|
608
|
+
raise HTTPException(404, "Task not found")
|
|
609
|
+
new_id = str(uuid.uuid4())
|
|
610
|
+
forked = {
|
|
611
|
+
"id": new_id,
|
|
612
|
+
"label": f"⑂ {original['label']}",
|
|
613
|
+
"status": "stopped",
|
|
614
|
+
"history": list(original["history"]),
|
|
615
|
+
"session_id": None,
|
|
616
|
+
"cwd": original["cwd"],
|
|
617
|
+
"mode": original["mode"],
|
|
618
|
+
"model": original["model"],
|
|
619
|
+
"prompt": original["prompt"],
|
|
620
|
+
"created_at": datetime.now().isoformat(),
|
|
621
|
+
"_stop": False,
|
|
622
|
+
"total_cost": 0,
|
|
623
|
+
"usage": {},
|
|
624
|
+
"branch": original.get("branch", ""),
|
|
625
|
+
"extra": {**original.get("extra", {}), "fork": True},
|
|
626
|
+
"advisor": original.get("advisor", ""),
|
|
627
|
+
"advisor_model": original.get("advisor_model", ""),
|
|
628
|
+
}
|
|
629
|
+
_tasks[new_id] = forked
|
|
630
|
+
_save(forked)
|
|
631
|
+
return {"id": new_id, "label": forked["label"], "status": "stopped", "forked_from": task_id}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
@app.delete("/tasks/{task_id}")
|
|
635
|
+
async def delete_task(task_id: str):
|
|
636
|
+
"""Soft-delete: move task to trash."""
|
|
637
|
+
task = _tasks.get(task_id)
|
|
638
|
+
if not task:
|
|
639
|
+
raise HTTPException(404, "Task not found")
|
|
640
|
+
task["_stop"] = True
|
|
641
|
+
provider = _providers.pop(task_id, None)
|
|
642
|
+
if provider:
|
|
643
|
+
await provider.stop()
|
|
644
|
+
task["deleted_at"] = datetime.now().isoformat()
|
|
645
|
+
if task["status"] == "running":
|
|
646
|
+
task["status"] = "stopped"
|
|
647
|
+
task["finished_at"] = datetime.now().isoformat()
|
|
648
|
+
_save(task)
|
|
649
|
+
return {"status": "trashed"}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
@app.get("/trash")
|
|
653
|
+
async def list_trash():
|
|
654
|
+
"""List trashed tasks."""
|
|
655
|
+
result = []
|
|
656
|
+
for t in sorted(_tasks.values(), key=lambda x: x.get("deleted_at") or "", reverse=True):
|
|
657
|
+
if not t.get("deleted_at"):
|
|
658
|
+
continue
|
|
659
|
+
result.append({
|
|
660
|
+
"id": t["id"], "label": t["label"], "status": t["status"],
|
|
661
|
+
"created_at": t["created_at"], "finished_at": t.get("finished_at"),
|
|
662
|
+
"deleted_at": t["deleted_at"],
|
|
663
|
+
"cwd": t["cwd"], "model": t.get("model"),
|
|
664
|
+
"total_cost": t.get("total_cost", 0),
|
|
665
|
+
})
|
|
666
|
+
return result
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@app.post("/trash/{task_id}/restore")
|
|
670
|
+
async def restore_task(task_id: str):
|
|
671
|
+
"""Restore a trashed task."""
|
|
672
|
+
task = _tasks.get(task_id)
|
|
673
|
+
if not task:
|
|
674
|
+
raise HTTPException(404, "Task not found")
|
|
675
|
+
if not task.get("deleted_at"):
|
|
676
|
+
raise HTTPException(400, "Task is not in trash")
|
|
677
|
+
task["deleted_at"] = None
|
|
678
|
+
_save(task)
|
|
679
|
+
return {"status": "restored"}
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
@app.delete("/trash/{task_id}")
|
|
683
|
+
async def permanent_delete(task_id: str):
|
|
684
|
+
"""Permanently delete a trashed task."""
|
|
685
|
+
task = _tasks.pop(task_id, None)
|
|
686
|
+
if not task:
|
|
687
|
+
raise HTTPException(404, "Task not found")
|
|
688
|
+
task["_deleted"] = True
|
|
689
|
+
with _db() as con:
|
|
690
|
+
con.execute("DELETE FROM tasks WHERE id=?", (task_id,))
|
|
691
|
+
return {"status": "deleted"}
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# ── Routes: Git ──────────────────────────────────────────────────────
|
|
695
|
+
@app.get("/tasks/{task_id}/gitdiff")
|
|
696
|
+
async def get_gitdiff(task_id: str):
|
|
697
|
+
task = _tasks.get(task_id)
|
|
698
|
+
if not task:
|
|
699
|
+
raise HTTPException(404, "Task not found")
|
|
700
|
+
return await GitService.get_diff(task["cwd"] or os.getcwd())
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@app.get("/git/status")
|
|
704
|
+
async def git_status(cwd: str = ""):
|
|
705
|
+
return await GitService.get_status(cwd or os.getcwd())
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
@app.get("/git/branches")
|
|
709
|
+
async def git_branches(cwd: str = ""):
|
|
710
|
+
return await GitService.list_branches(cwd or os.getcwd())
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@app.post("/git/branch")
|
|
714
|
+
async def git_create_branch(req: GitBranchRequest, cwd: str = ""):
|
|
715
|
+
return await GitService.create_branch(cwd or os.getcwd(), req.name, req.checkout)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
@app.post("/git/switch")
|
|
719
|
+
async def git_switch_branch(req: GitBranchRequest, cwd: str = ""):
|
|
720
|
+
return await GitService.switch_branch(cwd or os.getcwd(), req.name)
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@app.post("/git/commit")
|
|
724
|
+
async def git_commit(req: GitCommitRequest, cwd: str = ""):
|
|
725
|
+
return await GitService.commit(cwd or os.getcwd(), req.message, req.add_all)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@app.get("/git/log")
|
|
729
|
+
async def git_log(cwd: str = "", n: int = 20):
|
|
730
|
+
return await GitService.get_log(cwd or os.getcwd(), n)
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
# ── Routes: Workspaces ──────────────────────────────────────────────
|
|
734
|
+
@app.get("/workspaces")
|
|
735
|
+
async def list_workspaces():
|
|
736
|
+
with _db() as con:
|
|
737
|
+
rows = con.execute("SELECT id, name, path FROM workspaces ORDER BY name").fetchall()
|
|
738
|
+
return [{"id": r[0], "name": r[1], "path": r[2]} for r in rows]
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
@app.post("/workspaces")
|
|
742
|
+
async def add_workspace(req: WorkspaceRequest):
|
|
743
|
+
path = os.path.expanduser(req.path)
|
|
744
|
+
if not os.path.isdir(path):
|
|
745
|
+
raise HTTPException(400, "Path is not a directory")
|
|
746
|
+
name = req.name or os.path.basename(path.rstrip("/"))
|
|
747
|
+
ws_id = str(uuid.uuid4())
|
|
748
|
+
try:
|
|
749
|
+
with _db() as con:
|
|
750
|
+
con.execute("INSERT INTO workspaces (id, name, path) VALUES (?,?,?)", (ws_id, name, path))
|
|
751
|
+
except sqlite3.IntegrityError:
|
|
752
|
+
raise HTTPException(409, "Workspace already exists")
|
|
753
|
+
return {"id": ws_id, "name": name, "path": path}
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
@app.delete("/workspaces/{ws_id}")
|
|
757
|
+
async def remove_workspace(ws_id: str):
|
|
758
|
+
with _db() as con:
|
|
759
|
+
n = con.execute("DELETE FROM workspaces WHERE id=?", (ws_id,)).rowcount
|
|
760
|
+
if not n:
|
|
761
|
+
raise HTTPException(404, "Workspace not found")
|
|
762
|
+
return {"status": "deleted"}
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
@app.get("/analytics")
|
|
766
|
+
async def analytics():
|
|
767
|
+
"""Return dashboard analytics computed from tasks DB."""
|
|
768
|
+
with _db() as con:
|
|
769
|
+
con.row_factory = sqlite3.Row
|
|
770
|
+
rows = con.execute("""
|
|
771
|
+
SELECT id, label, status, cwd, mode, model, total_cost,
|
|
772
|
+
usage, created_at, finished_at, deleted_at
|
|
773
|
+
FROM tasks WHERE deleted_at IS NULL
|
|
774
|
+
""").fetchall()
|
|
775
|
+
|
|
776
|
+
tasks_list = [dict(r) for r in rows]
|
|
777
|
+
for t in tasks_list:
|
|
778
|
+
t["usage"] = json.loads(t["usage"] or "{}")
|
|
779
|
+
t["total_cost"] = t["total_cost"] or 0
|
|
780
|
+
|
|
781
|
+
total_tasks = len(tasks_list)
|
|
782
|
+
total_cost = sum(t["total_cost"] for t in tasks_list)
|
|
783
|
+
total_input = sum((t["usage"].get("input_tokens", 0) + t["usage"].get("cache_read_input_tokens", 0) + t["usage"].get("cache_creation_input_tokens", 0)) for t in tasks_list)
|
|
784
|
+
total_output = sum(t["usage"].get("output_tokens", 0) for t in tasks_list)
|
|
785
|
+
by_status = {}
|
|
786
|
+
for t in tasks_list:
|
|
787
|
+
by_status[t["status"]] = by_status.get(t["status"], 0) + 1
|
|
788
|
+
|
|
789
|
+
# Folder breakdown
|
|
790
|
+
folder_map: dict[str, dict] = {}
|
|
791
|
+
for t in tasks_list:
|
|
792
|
+
cwd = t["cwd"] or "unknown"
|
|
793
|
+
# Normalize trailing slashes
|
|
794
|
+
cwd = cwd.rstrip("/")
|
|
795
|
+
if cwd not in folder_map:
|
|
796
|
+
folder_map[cwd] = {"path": cwd, "tasks": 0, "cost": 0, "input_tokens": 0, "output_tokens": 0, "running": 0, "done": 0, "error": 0, "stopped": 0, "models": set()}
|
|
797
|
+
f = folder_map[cwd]
|
|
798
|
+
f["tasks"] += 1
|
|
799
|
+
f["cost"] += t["total_cost"]
|
|
800
|
+
f["input_tokens"] += (t["usage"].get("input_tokens", 0) + t["usage"].get("cache_read_input_tokens", 0) + t["usage"].get("cache_creation_input_tokens", 0))
|
|
801
|
+
f["output_tokens"] += t["usage"].get("output_tokens", 0)
|
|
802
|
+
if t["status"] in f:
|
|
803
|
+
f[t["status"]] += 1
|
|
804
|
+
if t["model"]:
|
|
805
|
+
f["models"].add(t["model"])
|
|
806
|
+
folders = sorted(folder_map.values(), key=lambda x: x["tasks"], reverse=True)
|
|
807
|
+
for f in folders:
|
|
808
|
+
f["models"] = list(f["models"])
|
|
809
|
+
|
|
810
|
+
# Agent/model breakdown
|
|
811
|
+
model_map: dict[str, dict] = {}
|
|
812
|
+
for t in tasks_list:
|
|
813
|
+
m = t["model"] or "unknown"
|
|
814
|
+
if m not in model_map:
|
|
815
|
+
model_map[m] = {"model": m, "tasks": 0, "cost": 0, "input_tokens": 0, "output_tokens": 0}
|
|
816
|
+
e = model_map[m]
|
|
817
|
+
e["tasks"] += 1
|
|
818
|
+
e["cost"] += t["total_cost"]
|
|
819
|
+
e["input_tokens"] += (t["usage"].get("input_tokens", 0) + t["usage"].get("cache_read_input_tokens", 0) + t["usage"].get("cache_creation_input_tokens", 0))
|
|
820
|
+
e["output_tokens"] += t["usage"].get("output_tokens", 0)
|
|
821
|
+
models = sorted(model_map.values(), key=lambda x: x["tasks"], reverse=True)
|
|
822
|
+
|
|
823
|
+
# Daily timeline (last 30 days)
|
|
824
|
+
from collections import defaultdict
|
|
825
|
+
daily: dict[str, dict] = defaultdict(lambda: {"date": "", "tasks": 0, "cost": 0, "input_tokens": 0, "output_tokens": 0})
|
|
826
|
+
for t in tasks_list:
|
|
827
|
+
if t["created_at"]:
|
|
828
|
+
day = t["created_at"][:10]
|
|
829
|
+
d = daily[day]
|
|
830
|
+
d["date"] = day
|
|
831
|
+
d["tasks"] += 1
|
|
832
|
+
d["cost"] += t["total_cost"]
|
|
833
|
+
d["input_tokens"] += (t["usage"].get("input_tokens", 0) + t["usage"].get("cache_read_input_tokens", 0) + t["usage"].get("cache_creation_input_tokens", 0))
|
|
834
|
+
d["output_tokens"] += t["usage"].get("output_tokens", 0)
|
|
835
|
+
timeline = sorted(daily.values(), key=lambda x: x["date"])[-30:]
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
"summary": {
|
|
839
|
+
"total_tasks": total_tasks,
|
|
840
|
+
"total_cost": total_cost,
|
|
841
|
+
"total_input_tokens": total_input,
|
|
842
|
+
"total_output_tokens": total_output,
|
|
843
|
+
"by_status": by_status,
|
|
844
|
+
},
|
|
845
|
+
"folders": folders,
|
|
846
|
+
"models": models,
|
|
847
|
+
"timeline": timeline,
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
# ── Routes: Git file-level operations ────────────────────────────────
|
|
852
|
+
@app.get("/git/changed-files")
|
|
853
|
+
async def git_changed_files(cwd: str = ""):
|
|
854
|
+
cwd = cwd or os.getcwd()
|
|
855
|
+
try:
|
|
856
|
+
proc = await asyncio.create_subprocess_exec(
|
|
857
|
+
"git", "status", "--porcelain", "-u",
|
|
858
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
859
|
+
)
|
|
860
|
+
stdout, stderr = await proc.communicate()
|
|
861
|
+
if proc.returncode != 0:
|
|
862
|
+
return {"error": stderr.decode().strip(), "files": [], "branch": ""}
|
|
863
|
+
files = []
|
|
864
|
+
for line in stdout.decode().splitlines():
|
|
865
|
+
if len(line) < 4:
|
|
866
|
+
continue
|
|
867
|
+
ix, wt = line[0], line[1]
|
|
868
|
+
filepath = line[3:]
|
|
869
|
+
staged = ix not in (" ", "?", "!")
|
|
870
|
+
unstaged = wt not in (" ", "?", "!")
|
|
871
|
+
untracked = ix == "?" and wt == "?"
|
|
872
|
+
files.append({"path": filepath, "index": ix, "working": wt,
|
|
873
|
+
"staged": staged, "unstaged": unstaged, "untracked": untracked})
|
|
874
|
+
bp = await asyncio.create_subprocess_exec(
|
|
875
|
+
"git", "branch", "--show-current",
|
|
876
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
877
|
+
)
|
|
878
|
+
bo, _ = await bp.communicate()
|
|
879
|
+
return {"files": files, "branch": bo.decode().strip()}
|
|
880
|
+
except Exception as e:
|
|
881
|
+
return {"error": str(e), "files": [], "branch": ""}
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
@app.post("/git/stage")
|
|
885
|
+
async def git_stage(req: GitStageRequest, cwd: str = ""):
|
|
886
|
+
cwd = cwd or os.getcwd()
|
|
887
|
+
proc = await asyncio.create_subprocess_exec(
|
|
888
|
+
"git", "add", "--", *req.files,
|
|
889
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
890
|
+
)
|
|
891
|
+
_, stderr = await proc.communicate()
|
|
892
|
+
if proc.returncode != 0:
|
|
893
|
+
raise HTTPException(400, stderr.decode().strip())
|
|
894
|
+
return {"status": "staged", "files": req.files}
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
@app.post("/git/unstage")
|
|
898
|
+
async def git_unstage(req: GitStageRequest, cwd: str = ""):
|
|
899
|
+
cwd = cwd or os.getcwd()
|
|
900
|
+
proc = await asyncio.create_subprocess_exec(
|
|
901
|
+
"git", "reset", "HEAD", "--", *req.files,
|
|
902
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
903
|
+
)
|
|
904
|
+
_, stderr = await proc.communicate()
|
|
905
|
+
if proc.returncode != 0:
|
|
906
|
+
raise HTTPException(400, stderr.decode().strip())
|
|
907
|
+
return {"status": "unstaged", "files": req.files}
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
@app.get("/git/file-diff")
|
|
911
|
+
async def git_file_diff(file: str, cwd: str = "", staged: bool = False):
|
|
912
|
+
cwd = cwd or os.getcwd()
|
|
913
|
+
cmd = ["git", "diff"]
|
|
914
|
+
if staged:
|
|
915
|
+
cmd.append("--cached")
|
|
916
|
+
cmd.extend(["--", file])
|
|
917
|
+
proc = await asyncio.create_subprocess_exec(
|
|
918
|
+
*cmd, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
919
|
+
)
|
|
920
|
+
stdout, _ = await proc.communicate()
|
|
921
|
+
return {"file": file, "diff": stdout.decode(), "staged": staged}
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
@app.post("/git/push")
|
|
925
|
+
async def git_push(cwd: str = "", set_upstream: bool = False):
|
|
926
|
+
cwd = cwd or os.getcwd()
|
|
927
|
+
cmd = ["git", "push"]
|
|
928
|
+
if set_upstream:
|
|
929
|
+
bp = await asyncio.create_subprocess_exec(
|
|
930
|
+
"git", "branch", "--show-current",
|
|
931
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
932
|
+
)
|
|
933
|
+
bo, _ = await bp.communicate()
|
|
934
|
+
branch = bo.decode().strip()
|
|
935
|
+
cmd = ["git", "push", "--set-upstream", "origin", branch]
|
|
936
|
+
proc = await asyncio.create_subprocess_exec(
|
|
937
|
+
*cmd, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
938
|
+
)
|
|
939
|
+
stdout, stderr = await proc.communicate()
|
|
940
|
+
output = (stdout.decode() + stderr.decode()).strip()
|
|
941
|
+
if proc.returncode != 0:
|
|
942
|
+
if not set_upstream and ("no upstream" in output.lower() or "set-upstream" in output.lower()):
|
|
943
|
+
return await git_push(cwd=cwd, set_upstream=True)
|
|
944
|
+
raise HTTPException(400, output)
|
|
945
|
+
return {"status": "pushed", "output": output}
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
@app.get("/git/prs")
|
|
949
|
+
async def git_list_prs(cwd: str = ""):
|
|
950
|
+
cwd = cwd or os.getcwd()
|
|
951
|
+
proc = await asyncio.create_subprocess_exec(
|
|
952
|
+
"gh", "pr", "list",
|
|
953
|
+
"--json", "number,title,author,headRefName,baseRefName,state,url,createdAt,isDraft",
|
|
954
|
+
"--limit", "20",
|
|
955
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
956
|
+
)
|
|
957
|
+
stdout, stderr = await proc.communicate()
|
|
958
|
+
if proc.returncode != 0:
|
|
959
|
+
raise HTTPException(400, stderr.decode().strip())
|
|
960
|
+
return json.loads(stdout.decode())
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
@app.post("/git/pr/create")
|
|
964
|
+
async def git_create_pr(req: GitPRCreateRequest, cwd: str = ""):
|
|
965
|
+
cwd = cwd or os.getcwd()
|
|
966
|
+
cmd = ["gh", "pr", "create", "--title", req.title, "--body", req.body, "--base", req.base]
|
|
967
|
+
if req.head:
|
|
968
|
+
cmd.extend(["--head", req.head])
|
|
969
|
+
proc = await asyncio.create_subprocess_exec(
|
|
970
|
+
*cmd, cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
971
|
+
)
|
|
972
|
+
stdout, stderr = await proc.communicate()
|
|
973
|
+
output = (stdout.decode() + stderr.decode()).strip()
|
|
974
|
+
if proc.returncode != 0:
|
|
975
|
+
raise HTTPException(400, output)
|
|
976
|
+
return {"status": "created", "url": stdout.decode().strip()}
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
@app.post("/git/pr/merge")
|
|
980
|
+
async def git_merge_pr(req: GitPRMergeRequest, cwd: str = ""):
|
|
981
|
+
cwd = cwd or os.getcwd()
|
|
982
|
+
if req.method not in ("merge", "squash", "rebase"):
|
|
983
|
+
raise HTTPException(400, "method must be merge, squash, or rebase")
|
|
984
|
+
proc = await asyncio.create_subprocess_exec(
|
|
985
|
+
"gh", "pr", "merge", str(req.pr_number), f"--{req.method}",
|
|
986
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
987
|
+
)
|
|
988
|
+
stdout, stderr = await proc.communicate()
|
|
989
|
+
output = (stdout.decode() + stderr.decode()).strip()
|
|
990
|
+
if proc.returncode != 0:
|
|
991
|
+
raise HTTPException(400, output)
|
|
992
|
+
return {"status": "merged", "output": output}
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@app.get("/git/pr/{pr_number}/diff")
|
|
996
|
+
async def git_pr_diff(pr_number: int, cwd: str = ""):
|
|
997
|
+
cwd = cwd or os.getcwd()
|
|
998
|
+
proc = await asyncio.create_subprocess_exec(
|
|
999
|
+
"gh", "pr", "diff", str(pr_number),
|
|
1000
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1001
|
+
)
|
|
1002
|
+
stdout, stderr = await proc.communicate()
|
|
1003
|
+
if proc.returncode != 0:
|
|
1004
|
+
raise HTTPException(400, stderr.decode().strip())
|
|
1005
|
+
return {"pr_number": pr_number, "diff": stdout.decode()}
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
@app.get("/git/all-diffs")
|
|
1009
|
+
async def git_all_diffs(cwd: str = ""):
|
|
1010
|
+
cwd = cwd or os.getcwd()
|
|
1011
|
+
proc1 = await asyncio.create_subprocess_exec(
|
|
1012
|
+
"git", "diff", "--cached",
|
|
1013
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1014
|
+
)
|
|
1015
|
+
staged_out, _ = await proc1.communicate()
|
|
1016
|
+
proc2 = await asyncio.create_subprocess_exec(
|
|
1017
|
+
"git", "diff",
|
|
1018
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1019
|
+
)
|
|
1020
|
+
unstaged_out, _ = await proc2.communicate()
|
|
1021
|
+
proc3 = await asyncio.create_subprocess_exec(
|
|
1022
|
+
"git", "ls-files", "--others", "--exclude-standard",
|
|
1023
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1024
|
+
)
|
|
1025
|
+
untracked_out, _ = await proc3.communicate()
|
|
1026
|
+
untracked_diff = ""
|
|
1027
|
+
for f in untracked_out.decode().strip().splitlines():
|
|
1028
|
+
if f:
|
|
1029
|
+
try:
|
|
1030
|
+
fpath = os.path.join(cwd, f)
|
|
1031
|
+
if os.path.isfile(fpath) and os.path.getsize(fpath) < 50000:
|
|
1032
|
+
with open(fpath, 'r', errors='replace') as fh:
|
|
1033
|
+
content = fh.read()
|
|
1034
|
+
untracked_diff += f"\n--- /dev/null\n+++ b/{f}\n" + "\n".join("+" + line for line in content.splitlines()) + "\n"
|
|
1035
|
+
except Exception:
|
|
1036
|
+
pass
|
|
1037
|
+
return {
|
|
1038
|
+
"staged": staged_out.decode(),
|
|
1039
|
+
"unstaged": unstaged_out.decode(),
|
|
1040
|
+
"untracked": untracked_diff,
|
|
1041
|
+
"total": staged_out.decode() + "\n" + unstaged_out.decode() + "\n" + untracked_diff,
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
async def _generate_commit_message(diff_text: str) -> str:
|
|
1046
|
+
"""Call vLLM to generate commit message from diff."""
|
|
1047
|
+
import httpx
|
|
1048
|
+
try:
|
|
1049
|
+
async with httpx.AsyncClient(timeout=60) as client:
|
|
1050
|
+
resp = await client.post(
|
|
1051
|
+
"http://192.168.170.49:8000/v1/chat/completions",
|
|
1052
|
+
json={
|
|
1053
|
+
"model": "",
|
|
1054
|
+
"messages": [
|
|
1055
|
+
{"role": "system", "content": "Generate a concise conventional commit message (type: description format) for the following diff. Return ONLY the commit message, no explanation. /no_think"},
|
|
1056
|
+
{"role": "user", "content": diff_text[:8000]},
|
|
1057
|
+
],
|
|
1058
|
+
"max_tokens": 300,
|
|
1059
|
+
"temperature": 0.3,
|
|
1060
|
+
},
|
|
1061
|
+
)
|
|
1062
|
+
data = resp.json()
|
|
1063
|
+
msg = data["choices"][0]["message"]["content"].strip()
|
|
1064
|
+
msg = msg.strip('`"\'')
|
|
1065
|
+
# Strip Qwen3 thinking content
|
|
1066
|
+
if '</think>' in msg:
|
|
1067
|
+
msg = msg.split('</think>')[-1].strip()
|
|
1068
|
+
if '<think>' in msg:
|
|
1069
|
+
msg = msg.split('<think>')[0].strip()
|
|
1070
|
+
# If msg still has multi-line thinking, take last non-empty line
|
|
1071
|
+
lines = [l.strip() for l in msg.splitlines() if l.strip()]
|
|
1072
|
+
if len(lines) > 3:
|
|
1073
|
+
# Likely thinking leaked — grab lines that look like a commit msg
|
|
1074
|
+
for l in reversed(lines):
|
|
1075
|
+
if ':' in l and len(l) < 200:
|
|
1076
|
+
return l.strip('`"\'')
|
|
1077
|
+
return lines[-1].strip('`"\'') if lines else msg
|
|
1078
|
+
except Exception:
|
|
1079
|
+
return f"Update: changes in {diff_text.count('diff --git')} file(s)"
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
@app.post("/git/ai-commit-message")
|
|
1083
|
+
async def git_ai_commit_message(cwd: str = ""):
|
|
1084
|
+
cwd = cwd or os.getcwd()
|
|
1085
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1086
|
+
"git", "diff", "--cached",
|
|
1087
|
+
cwd=cwd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1088
|
+
)
|
|
1089
|
+
stdout, _ = await proc.communicate()
|
|
1090
|
+
diff_text = stdout.decode().strip()
|
|
1091
|
+
if not diff_text:
|
|
1092
|
+
raise HTTPException(400, "No staged changes to generate commit message from")
|
|
1093
|
+
message = await _generate_commit_message(diff_text)
|
|
1094
|
+
return {"message": message}
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
# ── Routes: Scheduler ────────────────────────────────────────────────
|
|
1098
|
+
@app.get("/scheduler/jobs")
|
|
1099
|
+
async def list_jobs():
|
|
1100
|
+
return scheduler.list_jobs()
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
@app.post("/scheduler/jobs")
|
|
1104
|
+
async def create_job(req: CreateJobRequest):
|
|
1105
|
+
job = scheduler.add_job(
|
|
1106
|
+
name=req.name, prompt=req.prompt, model=req.model,
|
|
1107
|
+
mode=req.mode, cwd=req.cwd, schedule=req.schedule,
|
|
1108
|
+
interval_seconds=req.interval_seconds,
|
|
1109
|
+
delay_seconds=req.delay_seconds, one_shot=req.one_shot,
|
|
1110
|
+
)
|
|
1111
|
+
return job.to_dict()
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
@app.delete("/scheduler/jobs/{job_id}")
|
|
1115
|
+
async def delete_job(job_id: str):
|
|
1116
|
+
if scheduler.remove_job(job_id):
|
|
1117
|
+
return {"status": "deleted"}
|
|
1118
|
+
raise HTTPException(404, "Job not found")
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
@app.post("/scheduler/jobs/{job_id}/toggle")
|
|
1122
|
+
async def toggle_job(job_id: str):
|
|
1123
|
+
if scheduler.toggle_job(job_id):
|
|
1124
|
+
return {"status": "toggled"}
|
|
1125
|
+
raise HTTPException(404, "Job not found")
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
# ── Routes: Monitor ──────────────────────────────────────────────────
|
|
1129
|
+
@app.get("/monitor")
|
|
1130
|
+
async def monitor_dashboard():
|
|
1131
|
+
return monitor.get_dashboard()
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
@app.get("/monitor/health")
|
|
1135
|
+
async def monitor_health():
|
|
1136
|
+
return await monitor.check_providers()
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
@app.get("/monitor/metrics")
|
|
1140
|
+
async def monitor_metrics():
|
|
1141
|
+
return monitor.get_metrics()
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
# ── Routes: Providers ────────────────────────────────────────────────
|
|
1145
|
+
@app.get("/providers")
|
|
1146
|
+
async def providers_list():
|
|
1147
|
+
return list_providers()
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
@app.get("/providers/health")
|
|
1151
|
+
async def providers_health():
|
|
1152
|
+
return await health_check_all()
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
# ── Routes: Directory autocomplete ───────────────────────────────────
|
|
1156
|
+
@app.get("/suggest")
|
|
1157
|
+
async def suggest_path(path: str = ""):
|
|
1158
|
+
try:
|
|
1159
|
+
path = os.path.expanduser(path) if path else ""
|
|
1160
|
+
if not path:
|
|
1161
|
+
# Return home subdirs as starting suggestions
|
|
1162
|
+
home = os.path.expanduser("~")
|
|
1163
|
+
return sorted([
|
|
1164
|
+
os.path.join(home, e) for e in os.listdir(home)
|
|
1165
|
+
if not e.startswith(".") and os.path.isdir(os.path.join(home, e))
|
|
1166
|
+
])[:20]
|
|
1167
|
+
|
|
1168
|
+
# If path ends with / and is a dir, list its children
|
|
1169
|
+
if path.endswith("/") and os.path.isdir(path):
|
|
1170
|
+
results = [
|
|
1171
|
+
os.path.join(path, e) for e in sorted(os.listdir(path))
|
|
1172
|
+
if not e.startswith(".") and os.path.isdir(os.path.join(path, e))
|
|
1173
|
+
]
|
|
1174
|
+
return results[:20]
|
|
1175
|
+
|
|
1176
|
+
# If exact dir exists, list children
|
|
1177
|
+
if os.path.isdir(path):
|
|
1178
|
+
results = [
|
|
1179
|
+
os.path.join(path, e) for e in sorted(os.listdir(path))
|
|
1180
|
+
if not e.startswith(".") and os.path.isdir(os.path.join(path, e))
|
|
1181
|
+
]
|
|
1182
|
+
return results[:20]
|
|
1183
|
+
|
|
1184
|
+
# Partial match: search parent dir for matching names
|
|
1185
|
+
search_dir = os.path.dirname(path) or os.path.expanduser("~")
|
|
1186
|
+
pattern = os.path.basename(path).lower()
|
|
1187
|
+
|
|
1188
|
+
if os.path.isdir(search_dir):
|
|
1189
|
+
quick = sorted([
|
|
1190
|
+
os.path.join(search_dir, e) for e in os.listdir(search_dir)
|
|
1191
|
+
if not e.startswith(".") and os.path.isdir(os.path.join(search_dir, e))
|
|
1192
|
+
and pattern in e.lower()
|
|
1193
|
+
])
|
|
1194
|
+
if quick:
|
|
1195
|
+
return quick[:20]
|
|
1196
|
+
|
|
1197
|
+
# Deep search across home directory
|
|
1198
|
+
home = os.path.expanduser("~")
|
|
1199
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1200
|
+
"find", home, "-maxdepth", "4", "-type", "d",
|
|
1201
|
+
"-iname", f"*{pattern}*", "-not", "-path", "*/.*",
|
|
1202
|
+
"-not", "-path", "*/__pycache__/*", "-not", "-path", "*/node_modules/*",
|
|
1203
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL
|
|
1204
|
+
)
|
|
1205
|
+
try:
|
|
1206
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=2.0)
|
|
1207
|
+
except asyncio.TimeoutError:
|
|
1208
|
+
proc.kill()
|
|
1209
|
+
stdout = b""
|
|
1210
|
+
results = [l for l in stdout.decode().splitlines() if "/." not in l and l.strip()]
|
|
1211
|
+
# Sort: shorter paths first (more likely what user wants)
|
|
1212
|
+
results.sort(key=lambda x: (x.count("/"), len(x)))
|
|
1213
|
+
return results[:20]
|
|
1214
|
+
except Exception:
|
|
1215
|
+
return []
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
@app.get("/browse")
|
|
1219
|
+
async def browse_directory(path: str = ""):
|
|
1220
|
+
"""Browse directory contents for file manager — returns files and dirs."""
|
|
1221
|
+
try:
|
|
1222
|
+
path = os.path.expanduser(path) if path else os.path.expanduser("~")
|
|
1223
|
+
if not os.path.isdir(path):
|
|
1224
|
+
return {"error": "Not a directory", "entries": []}
|
|
1225
|
+
entries = []
|
|
1226
|
+
try:
|
|
1227
|
+
items = sorted(os.listdir(path), key=lambda x: (not os.path.isdir(os.path.join(path, x)), x.lower()))
|
|
1228
|
+
except PermissionError:
|
|
1229
|
+
return {"path": path, "entries": [], "error": "Permission denied"}
|
|
1230
|
+
for name in items:
|
|
1231
|
+
if name.startswith('.'):
|
|
1232
|
+
continue
|
|
1233
|
+
full = os.path.join(path, name)
|
|
1234
|
+
is_dir = os.path.isdir(full)
|
|
1235
|
+
try:
|
|
1236
|
+
stat = os.stat(full)
|
|
1237
|
+
size = stat.st_size if not is_dir else None
|
|
1238
|
+
except (OSError, PermissionError):
|
|
1239
|
+
size = None
|
|
1240
|
+
entries.append({
|
|
1241
|
+
"name": name,
|
|
1242
|
+
"path": full,
|
|
1243
|
+
"is_dir": is_dir,
|
|
1244
|
+
"size": size,
|
|
1245
|
+
})
|
|
1246
|
+
return {"path": path, "entries": entries[:200]}
|
|
1247
|
+
except Exception as e:
|
|
1248
|
+
return {"path": path, "entries": [], "error": str(e)}
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
# ── Agent definitions (static list for the UI) ──────────────────────
|
|
1252
|
+
_AGENTS = [
|
|
1253
|
+
{"id": "claude-code", "name": "Claude Code", "provider": "claude", "description": "Anthropic's agentic coding assistant"},
|
|
1254
|
+
{"id": "opencode", "name": "OpenCode", "provider": "opencode", "description": "Open-source agentic coding CLI"},
|
|
1255
|
+
{"id": "copilot", "name": "Copilot", "provider": "copilot", "description": "GitHub Copilot SDK agent"},
|
|
1256
|
+
{"id": "local", "name": "Local", "provider": "vllm", "description": "Local vLLM model (Qwen 3.5-9B)"},
|
|
1257
|
+
]
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
@app.get("/agents")
|
|
1261
|
+
async def list_agents():
|
|
1262
|
+
"""List all registered agents."""
|
|
1263
|
+
return _AGENTS
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
# ── OpenCode live model catalog ──────────────────────────────────────
|
|
1267
|
+
# `opencode models --verbose` prints "<provider>/<id>" then a JSON blob per
|
|
1268
|
+
# model. The free `opencode/*` tier rotates periodically (models added/removed
|
|
1269
|
+
# without notice), so we shell out and cache briefly instead of hardcoding —
|
|
1270
|
+
# a hardcoded list here is exactly how the free-model dropdown went stale.
|
|
1271
|
+
_opencode_models_cache: dict = {"at": 0.0, "models": []}
|
|
1272
|
+
_OPENCODE_MODELS_TTL = 300 # seconds
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def _parse_opencode_models_verbose(text: str) -> list[dict]:
|
|
1276
|
+
models = []
|
|
1277
|
+
lines = text.splitlines()
|
|
1278
|
+
i = 0
|
|
1279
|
+
while i < len(lines):
|
|
1280
|
+
line = lines[i].strip()
|
|
1281
|
+
if "/" in line and not line.startswith("{"):
|
|
1282
|
+
slug = line
|
|
1283
|
+
# Following lines form one JSON object (starts with '{', ends with matching '}')
|
|
1284
|
+
if i + 1 < len(lines) and lines[i + 1].strip().startswith("{"):
|
|
1285
|
+
depth = 0
|
|
1286
|
+
buf = []
|
|
1287
|
+
j = i + 1
|
|
1288
|
+
while j < len(lines):
|
|
1289
|
+
buf.append(lines[j])
|
|
1290
|
+
depth += lines[j].count("{") - lines[j].count("}")
|
|
1291
|
+
j += 1
|
|
1292
|
+
if depth <= 0:
|
|
1293
|
+
break
|
|
1294
|
+
try:
|
|
1295
|
+
obj = json.loads("\n".join(buf))
|
|
1296
|
+
except Exception:
|
|
1297
|
+
obj = {}
|
|
1298
|
+
cost = obj.get("cost", {}) or {}
|
|
1299
|
+
is_free = not cost.get("input") and not cost.get("output")
|
|
1300
|
+
models.append({
|
|
1301
|
+
"id": slug,
|
|
1302
|
+
"name": obj.get("name", slug),
|
|
1303
|
+
"provider": obj.get("providerID", slug.split("/")[0]),
|
|
1304
|
+
"free": is_free,
|
|
1305
|
+
"context": (obj.get("limit", {}) or {}).get("context"),
|
|
1306
|
+
})
|
|
1307
|
+
i = j
|
|
1308
|
+
continue
|
|
1309
|
+
i += 1
|
|
1310
|
+
return models
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
@app.get("/models/opencode")
|
|
1314
|
+
async def list_opencode_models(refresh: bool = False):
|
|
1315
|
+
"""Live OpenCode model catalog (free `opencode/*` tier + any authenticated providers).
|
|
1316
|
+
|
|
1317
|
+
Cached briefly since this shells out to the `opencode` CLI. Pass ?refresh=true
|
|
1318
|
+
to force a re-fetch (e.g. after `opencode auth login` or a free-tier rotation).
|
|
1319
|
+
"""
|
|
1320
|
+
now = datetime.now().timestamp()
|
|
1321
|
+
if not refresh and _opencode_models_cache["models"] and now - _opencode_models_cache["at"] < _OPENCODE_MODELS_TTL:
|
|
1322
|
+
return {"models": _opencode_models_cache["models"], "cached": True}
|
|
1323
|
+
|
|
1324
|
+
import shutil
|
|
1325
|
+
if shutil.which("opencode") is None:
|
|
1326
|
+
return {"models": [], "error": "opencode CLI not found on PATH"}
|
|
1327
|
+
|
|
1328
|
+
try:
|
|
1329
|
+
proc = await asyncio.create_subprocess_exec(
|
|
1330
|
+
"opencode", "models", "--verbose",
|
|
1331
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
1332
|
+
)
|
|
1333
|
+
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=20)
|
|
1334
|
+
models = _parse_opencode_models_verbose(stdout.decode(errors="replace"))
|
|
1335
|
+
if models:
|
|
1336
|
+
_opencode_models_cache["models"] = models
|
|
1337
|
+
_opencode_models_cache["at"] = now
|
|
1338
|
+
return {"models": models, "cached": False}
|
|
1339
|
+
except Exception as e:
|
|
1340
|
+
log.warning("list_opencode_models failed: %s", e)
|
|
1341
|
+
# Fall back to whatever we last had, even if stale, rather than an empty dropdown.
|
|
1342
|
+
return {"models": _opencode_models_cache["models"], "error": str(e)}
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
# ── Startup / Shutdown ───────────────────────────────────────────────
|
|
1346
|
+
@app.on_event("startup")
|
|
1347
|
+
async def startup():
|
|
1348
|
+
await scheduler.start()
|
|
1349
|
+
log.info("CC-UI v2 started — %d providers, %d agents, %d tasks loaded, %d scheduled jobs",
|
|
1350
|
+
len(list_providers()), len(_AGENTS), len(_tasks), len(scheduler.list_jobs()))
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
@app.on_event("shutdown")
|
|
1354
|
+
async def shutdown():
|
|
1355
|
+
await scheduler.stop()
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
# ── Main ─────────────────────────────────────────────────────────────
|
|
1359
|
+
def main():
|
|
1360
|
+
import argparse
|
|
1361
|
+
parser = argparse.ArgumentParser(prog="kiviai")
|
|
1362
|
+
parser.add_argument("command", nargs="?", default="serve", choices=["serve"],
|
|
1363
|
+
help="Command to run (default: serve)")
|
|
1364
|
+
parser.add_argument("--host", default="0.0.0.0", help="Bind address")
|
|
1365
|
+
parser.add_argument("-p", "--port", type=int, default=8001, help="Port number")
|
|
1366
|
+
parser.add_argument("--log-level", default="info", choices=["debug", "info", "warning", "error"])
|
|
1367
|
+
args = parser.parse_args()
|
|
1368
|
+
import uvicorn
|
|
1369
|
+
uvicorn.run(app, host=args.host, port=args.port, log_level=args.log_level)
|
|
1370
|
+
|
|
1371
|
+
if __name__ == "__main__":
|
|
1372
|
+
main()
|