Files
OpsPilot/server.py
T
wushenghuaandClaude Opus 4.7 92670cd667 Init OpsPilot - AI Coding Agent Control Center
A web dashboard for managing multiple Claude Code agents via tmux.
Features: task step tracking, remote confirmation, live terminal,
directory picker, real-time WebSocket updates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 14:52:45 +08:00

585 lines
20 KiB
Python

"""
OpsPilot - AI Coding Agent Control Center
Web dashboard on port 5016, manages multiple Claude Code agents via tmux.
"""
import asyncio
import json
import os
import subprocess
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
from fastapi.responses import HTMLResponse
import uvicorn
BASE_DIR = Path(__file__).resolve().parent
STATE_DIR = BASE_DIR / "state"
LOGS_DIR = BASE_DIR / "logs"
FRONTEND_DIR = BASE_DIR / "frontend"
for d in [STATE_DIR, LOGS_DIR]:
d.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="OpsPilot - Agent Control Center")
ws_clients: list[WebSocket] = []
log_buffer: list[dict] = []
MAX_LOGS = 500
def add_log(level: str, source: str, message: str):
entry = {
"time": datetime.now().strftime("%H:%M:%S"),
"ts": datetime.now().isoformat(),
"level": level,
"source": source,
"message": message,
}
log_buffer.append(entry)
if len(log_buffer) > MAX_LOGS:
log_buffer.pop(0)
with open(LOGS_DIR / "events.log", "a") as f:
f.write(f"[{entry['time']}] [{level}] [{source}] {message}\n")
def load_agents() -> dict:
"""Load agent registry from disk."""
path = STATE_DIR / "agents.json"
if path.exists():
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, IOError):
pass
return {}
def save_agents(agents: dict):
STATE_DIR.mkdir(parents=True, exist_ok=True)
with open(STATE_DIR / "agents.json", "w") as f:
json.dump(agents, f, indent=2, ensure_ascii=False)
def load_projects() -> dict:
path = STATE_DIR / "projects.json"
if path.exists():
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, IOError):
pass
return {}
def save_projects(projects: dict):
with open(STATE_DIR / "projects.json", "w") as f:
json.dump(projects, f, indent=2, ensure_ascii=False)
def load_tasks() -> dict:
"""Load task registry. Keyed by project name."""
path = STATE_DIR / "tasks.json"
if path.exists():
try:
return json.loads(path.read_text())
except (json.JSONDecodeError, IOError):
pass
return {}
def save_tasks(tasks: dict):
with open(STATE_DIR / "tasks.json", "w") as f:
json.dump(tasks, f, indent=2, ensure_ascii=False)
def get_tmux_status(session_name: str) -> dict:
"""Check if a tmux session exists and get its windows."""
try:
result = subprocess.run(
["tmux", "list-windows", "-t", session_name, "-F", "#{window_index}:#{window_name}:#{window_active}"],
capture_output=True, text=True, timeout=5
)
windows = []
active_window = None
for line in result.stdout.strip().split("\n"):
if line:
parts = line.split(":", 2)
if len(parts) == 3:
windows.append({"index": parts[0], "name": parts[1], "active": parts[2] == "1"})
if parts[2] == "1":
active_window = parts[0]
return {"running": True, "windows": windows, "active_window": active_window}
except Exception:
return {"running": False, "windows": [], "active_window": None}
async def broadcast(data: dict):
disconnected = []
for ws in ws_clients:
try:
await ws.send_json(data)
except Exception:
disconnected.append(ws)
for ws in disconnected:
ws_clients.remove(ws)
# ── REST API ──────────────────────────────────────────────
@app.get("/", response_class=HTMLResponse)
async def dashboard():
html_path = FRONTEND_DIR / "dashboard.html"
if html_path.exists():
return HTMLResponse(html_path.read_text(encoding="utf-8"))
return HTMLResponse("<h1>OpsPilot Dashboard</h1><p>dashboard.html not found</p>")
@app.get("/api/state")
async def get_state():
agents = load_agents()
projects = load_projects()
# Merge live tmux status
for name, agent in agents.items():
session = agent.get("tmux_session", "")
if session:
agent["tmux"] = get_tmux_status(session)
return {
"agents": agents,
"projects": projects,
"logs": log_buffer[-100:],
"time": datetime.now().isoformat(),
}
@app.post("/api/agents")
async def register_agent(data: dict):
"""Register or update an agent."""
agents = load_agents()
name = data.get("name", "unnamed")
agents[name] = {
"name": name,
"project": data.get("project", ""),
"tmux_session": data.get("tmux_session", ""),
"status": data.get("status", "idle"),
"task": data.get("task", ""),
"last_active": datetime.now().isoformat(),
"items_processed": data.get("items_processed", 0),
**(data.get("meta", {})),
}
save_agents(agents)
add_log("info", name, f"Agent registered: {data.get('status', 'idle')}")
await broadcast(await _build_state())
return {"status": "ok", "agent": agents[name]}
@app.post("/api/agents/{name}/status")
async def update_agent_status(name: str, data: dict):
agents = load_agents()
if name not in agents:
raise HTTPException(status_code=404, detail="Agent not found")
agents[name].update({
"status": data.get("status", agents[name]["status"]),
"task": data.get("task", agents[name].get("task", "")),
"last_active": datetime.now().isoformat(),
"items_processed": data.get("items_processed", agents[name].get("items_processed", 0)),
})
if "message" in data:
agents[name]["message"] = data["message"]
save_agents(agents)
add_log("info", name, f"Status: {data.get('status')} - {data.get('message', '')}")
await broadcast(await _build_state())
return {"status": "ok"}
@app.post("/api/projects")
async def upsert_project(data: dict):
projects = load_projects()
name = data.get("name", "unnamed")
# Merge with existing if present
existing = projects.get(name, {})
projects[name] = {
"name": name,
"path": data.get("path", existing.get("path", "")),
"status": data.get("status", existing.get("status", "active")),
"description": data.get("description", existing.get("description", "")),
"agents": data.get("agents", existing.get("agents", [])),
"kanban_status": data.get("kanban_status", existing.get("kanban_status", "in_progress")),
"updated": datetime.now().isoformat(),
}
save_projects(projects)
add_log("info", "project", f"Project updated: {name}")
await broadcast(await _build_state())
return {"status": "ok"}
# ── Task & Step Management ────────────────────────
@app.post("/api/tasks")
async def create_task(data: dict):
"""Create a new task for a project."""
project_name = data.get("project", "")
tasks = load_tasks()
task_id = str(int(time.time() * 1000))
steps_raw = data.get("steps", [])
# Support both step formats: ["name", ...] or [{"name": "...", "status": "..."}, ...]
steps = []
for s in steps_raw:
if isinstance(s, str):
steps.append({"name": s, "status": "pending", "started_at": None, "completed_at": None, "duration_seconds": 0})
else:
steps.append({
"name": s.get("name", ""),
"status": s.get("status", "pending"),
"started_at": s.get("started_at"),
"completed_at": s.get("completed_at"),
"duration_seconds": s.get("duration_seconds", 0),
})
task = {
"id": task_id,
"project": project_name,
"title": data.get("title", "Untitled Task"),
"description": data.get("description", ""),
"status": "queued", # queued, running, completed, failed
"steps": steps,
"current_step": 0,
"created_at": datetime.now().isoformat(),
"started_at": None,
"completed_at": None,
}
tasks[task_id] = task
save_tasks(tasks)
add_log("info", "task", f"Task created: {task['title']} for {project_name} ({len(steps)} steps)")
await broadcast(await _build_state())
return {"status": "ok", "task": task}
@app.post("/api/tasks/{task_id}/start")
async def start_task(task_id: str):
"""Mark a task as running and start its first step."""
tasks = load_tasks()
if task_id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
task = tasks[task_id]
task["status"] = "running"
task["started_at"] = datetime.now().isoformat()
if task["steps"]:
task["steps"][0]["status"] = "running"
task["steps"][0]["started_at"] = datetime.now().isoformat()
task["current_step"] = 0
save_tasks(tasks)
add_log("info", "task", f"Task started: {task['title']}")
await broadcast(await _build_state())
return {"status": "ok", "task": task}
@app.post("/api/tasks/{task_id}/steps/{step_index}/complete")
async def complete_step(task_id: str, step_index: int, data: dict = None):
"""Mark a step as completed and advance to the next."""
tasks = load_tasks()
if task_id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
task = tasks[task_id]
steps = task["steps"]
if step_index >= len(steps):
raise HTTPException(status_code=400, detail="Invalid step index")
now = datetime.now().isoformat()
step = steps[step_index]
step["status"] = "completed"
step["completed_at"] = now
if step.get("started_at"):
try:
start = datetime.fromisoformat(step["started_at"])
step["duration_seconds"] = int((datetime.now() - start).total_seconds())
except Exception:
pass
# Start next step if exists
next_idx = step_index + 1
if next_idx < len(steps):
steps[next_idx]["status"] = "running"
steps[next_idx]["started_at"] = now
task["current_step"] = next_idx
else:
# All steps done
task["status"] = "completed"
task["completed_at"] = now
task["current_step"] = len(steps)
save_tasks(tasks)
add_log("info", "task", f"Step {step_index+1}/{len(steps)} completed: {step['name']}")
await broadcast(await _build_state())
return {"status": "ok", "task": task, "completed_step": step}
@app.post("/api/tasks/{task_id}/steps/{step_index}/fail")
async def fail_step(task_id: str, step_index: int):
"""Mark a step as failed."""
tasks = load_tasks()
if task_id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
task = tasks[task_id]
step = task["steps"][step_index]
step["status"] = "failed"
step["completed_at"] = datetime.now().isoformat()
if step.get("started_at"):
try:
step["duration_seconds"] = int((datetime.now() - datetime.fromisoformat(step["started_at"])).total_seconds())
except Exception:
pass
save_tasks(tasks)
add_log("error", "task", f"Step failed: {step['name']}")
await broadcast(await _build_state())
return {"status": "ok", "step": step}
@app.post("/api/tasks/{task_id}/status")
async def update_task_status(task_id: str, data: dict):
"""Update task status."""
tasks = load_tasks()
if task_id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
task = tasks[task_id]
task["status"] = data.get("status", task["status"])
if data.get("status") == "completed":
task["completed_at"] = datetime.now().isoformat()
save_tasks(tasks)
add_log("info", "task", f"Task status: {task['status']}")
await broadcast(await _build_state())
return {"status": "ok", "task": task}
# ── Tmux Control ──────────────────────────────────
@app.get("/api/fs/list")
async def list_directory(path: str = ""):
"""List subdirectories for the path picker."""
import os as _os
target = path or str(Path.home() / "Documents" / "Projects")
target = _os.path.expanduser(target)
if not _os.path.isdir(target):
return {"path": target, "parent": str(Path(target).parent), "dirs": [], "error": "Not a directory"}
try:
items = []
for name in sorted(_os.listdir(target)):
full = _os.path.join(target, name)
if _os.path.isdir(full) and not name.startswith('.'):
items.append({"name": name, "path": full})
parent = str(Path(target).parent) if target != "/" else "/"
return {"path": target, "parent": parent, "dirs": items}
except PermissionError:
return {"path": target, "parent": str(Path(target).parent), "dirs": [], "error": "Permission denied"}
@app.post("/api/tmux/send")
async def tmux_send_keys(data: dict):
"""Send keys to a tmux session/window."""
session = data.get("session", "")
window = data.get("window", "0")
keys = data.get("keys", "")
if not session or not keys:
raise HTTPException(status_code=400, detail="session and keys required")
target = f"{session}:{window}"
try:
subprocess.run(
["tmux", "send-keys", "-t", target, keys, "Enter"],
capture_output=True, text=True, timeout=5
)
add_log("info", "tmux", f"Sent to {target}: {keys[:100]}")
return {"status": "ok", "target": target}
except Exception as e:
add_log("error", "tmux", str(e))
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/tmux/session")
async def create_tmux_session(data: dict):
"""Create a new tmux session with a Claude Code agent."""
session_name = data.get("name", "odineye")
project_dir = data.get("project_dir", "")
initial_prompt = data.get("prompt", "")
# Check if session exists
existing = get_tmux_status(session_name)
if existing["running"]:
return {"status": "exists", "session": session_name, "tmux": existing}
try:
# Create detached session
subprocess.run(
["tmux", "new-session", "-d", "-s", session_name, "-n", "dev", "-c", project_dir or str(BASE_DIR)],
capture_output=True, text=True, timeout=5
)
# Send initial Claude Code command
if initial_prompt:
time.sleep(1) # Wait for shell to init
escaped = initial_prompt.replace("'", "'\\''")
subprocess.run(
["tmux", "send-keys", "-t", f"{session_name}:dev", "claude", "Enter"],
capture_output=True, text=True, timeout=5
)
time.sleep(2) # Wait for Claude Code to start
subprocess.run(
["tmux", "send-keys", "-t", f"{session_name}:dev", escaped, "Enter"],
capture_output=True, text=True, timeout=5
)
add_log("info", "tmux", f"Session {session_name} created, Claude Code started")
return {"status": "created", "session": session_name, "tmux": get_tmux_status(session_name)}
except Exception as e:
add_log("error", "tmux", str(e))
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/tmux/capture")
async def tmux_capture(data: dict):
"""Capture pane content from a tmux session."""
session = data.get("session", "")
window = data.get("window", "0")
lines = int(data.get("lines", 50))
return _capture_tmux(session, window, lines)
@app.get("/api/tmux/live/{session}")
async def tmux_live(session: str, window: str = "0", lines: int = 80):
"""GET endpoint to capture tmux pane content (for live view)."""
return _capture_tmux(session, window, lines)
def _capture_tmux(session: str, window: str, lines: int) -> dict:
target = f"{session}:{window}"
try:
result = subprocess.run(
["tmux", "capture-pane", "-t", target, "-p", "-S", f"-{lines}"],
capture_output=True, text=True, timeout=5
)
# Strip ANSI escape codes for clean display
import re
clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', result.stdout)
return {"status": "ok", "content": clean, "target": target}
except Exception as e:
return {"status": "error", "content": str(e), "target": target}
# Cache for live terminal outputs
_tmux_cache: dict = {}
async def _capture_all_tmux_sessions():
"""Periodically capture all tmux sessions for live display."""
agents = load_agents()
for name, agent in agents.items():
session = agent.get("tmux_session", "")
if session:
window = agent.get("tmux_window", "0")
cap = _capture_tmux(session, window, 80)
_tmux_cache[session] = cap
return _tmux_cache
@app.post("/api/tmux/confirm")
async def tmux_confirm(data: dict):
"""Send confirmation (Enter, Y, or custom key) to a tmux session. Used to handle Claude Code prompts."""
session = data.get("session", "odineye")
window = data.get("window", "0")
# "yes" sends "1" then Enter (selects first option), "enter" just presses Enter
action = data.get("action", "enter")
target = f"{session}:{window}"
key_map = {"enter": "Enter", "yes": "1", "no": "2", "y": "y", "n": "n", "escape": "Escape", "yes_allow_all": "2"}
key = key_map.get(action, action)
needs_enter = action in ("yes", "y", "no", "n", "yes_allow_all")
try:
cmd = ["tmux", "send-keys", "-t", target, key]
if needs_enter:
cmd.append("Enter")
subprocess.run(cmd, capture_output=True, text=True, timeout=5)
add_log("info", "tmux", f"Confirmation sent to {target}: {action} ({key})")
return {"status": "ok", "target": target, "action": action}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/log")
async def add_log_entry(data: dict):
add_log(
data.get("level", "info"),
data.get("source", "system"),
data.get("message", "")
)
await broadcast(await _build_state())
return {"status": "ok"}
# ── WebSocket ─────────────────────────────────────────────
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
ws_clients.append(websocket)
add_log("info", "ws", f"Client connected ({len(ws_clients)} total)")
try:
# Send initial state
await websocket.send_json(await _build_state())
while True:
msg = await websocket.receive_text()
if msg == "ping":
await websocket.send_json(await _build_state())
except WebSocketDisconnect:
pass
except Exception:
pass
finally:
if websocket in ws_clients:
ws_clients.remove(websocket)
async def _build_state() -> dict:
agents = load_agents()
projects = load_projects()
for name, agent in agents.items():
session = agent.get("tmux_session", "")
if session:
agent["tmux"] = get_tmux_status(session)
# Include live terminal captures
await _capture_all_tmux_sessions()
# Find active task per project
all_tasks = load_tasks()
project_tasks = {}
for tid, t in all_tasks.items():
pn = t.get("project", "")
if pn not in project_tasks:
project_tasks[pn] = []
project_tasks[pn].append(t)
return {
"agents": agents,
"projects": projects,
"tasks": all_tasks,
"project_tasks": project_tasks,
"logs": log_buffer[-100:],
"terminals": _tmux_cache,
"time": datetime.now().isoformat(),
}
@app.on_event("startup")
async def startup():
add_log("info", "system", "OpsPilot Control Center starting on port 5016")
asyncio.create_task(_periodic_broadcast())
async def _periodic_broadcast():
while True:
await asyncio.sleep(10)
if ws_clients:
try:
await broadcast(await _build_state())
except Exception:
pass
if __name__ == "__main__":
add_log("info", "system", "Booting OpsPilot...")
uvicorn.run(app, host="0.0.0.0", port=5016, log_level="info")