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