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.
Files changed (47) hide show
  1. kivi_cli-2.0.6.dist-info/METADATA +17 -0
  2. kivi_cli-2.0.6.dist-info/RECORD +47 -0
  3. kivi_cli-2.0.6.dist-info/WHEEL +5 -0
  4. kivi_cli-2.0.6.dist-info/entry_points.txt +2 -0
  5. kivi_cli-2.0.6.dist-info/top_level.txt +1 -0
  6. kiviai/__init__.py +0 -0
  7. kiviai/backend.py +1372 -0
  8. kiviai/index.html +6172 -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/inhouse.py +130 -0
  17. kiviai/services/providers/kivi.py +108 -0
  18. kiviai/services/providers/model_costs.py +137 -0
  19. kiviai/services/providers/openai_agent.py +113 -0
  20. kiviai/services/providers/opencode.py +157 -0
  21. kiviai/services/providers/orchestrator_provider.py +163 -0
  22. kiviai/services/providers/registry.py +89 -0
  23. kiviai/services/providers/vllm.py +319 -0
  24. kiviai/services/scheduler.py +244 -0
  25. kiviai/services/tools/__init__.py +1 -0
  26. kiviai/services/tools/copilot_tools/__init__.py +92 -0
  27. kiviai/services/tools/copilot_tools/agent_orchestration/__init__.py +17 -0
  28. kiviai/services/tools/copilot_tools/agent_orchestration/tools.py +296 -0
  29. kiviai/services/tools/copilot_tools/bash_execution/__init__.py +20 -0
  30. kiviai/services/tools/copilot_tools/bash_execution/sessions.py +335 -0
  31. kiviai/services/tools/copilot_tools/code_search/__init__.py +10 -0
  32. kiviai/services/tools/copilot_tools/code_search/tools.py +137 -0
  33. kiviai/services/tools/copilot_tools/file_operations/__init__.py +12 -0
  34. kiviai/services/tools/copilot_tools/file_operations/tools.py +197 -0
  35. kiviai/services/tools/copilot_tools/markdown/__init__.py +41 -0
  36. kiviai/services/tools/copilot_tools/markdown/custom_markdownify.py +774 -0
  37. kiviai/services/tools/copilot_tools/markdown/mrkdwn_analysis.py +1155 -0
  38. kiviai/services/tools/copilot_tools/markdown/tools.py +357 -0
  39. kiviai/services/tools/copilot_tools/session_workflow/__init__.py +17 -0
  40. kiviai/services/tools/copilot_tools/session_workflow/tools.py +199 -0
  41. kiviai/services/tools/copilot_tools/web/__init__.py +37 -0
  42. kiviai/services/tools/copilot_tools/web/_fetcher.py +39 -0
  43. kiviai/services/tools/copilot_tools/web/_store.py +122 -0
  44. kiviai/services/tools/copilot_tools/web/fetch.py +20 -0
  45. kiviai/services/tools/copilot_tools/web/search.py +116 -0
  46. kiviai/services/tools/copilot_tools/web/store_tools.py +92 -0
  47. 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()