From 92670cd66754890bad393027d87091e5af46da5d Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Fri, 29 May 2026 14:52:45 +0800 Subject: [PATCH] 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 --- .gitignore | 6 + CLAUDE.md | 95 ++++++ README.md | 100 +++++++ frontend/dashboard.html | 645 ++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + server.py | 584 ++++++++++++++++++++++++++++++++++++ start.sh | 34 +++ 7 files changed, 1467 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 frontend/dashboard.html create mode 100644 requirements.txt create mode 100644 server.py create mode 100755 start.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fdc8900 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.pyc +logs/ +state/ +.env +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0710c16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# OpsPilot — AI Coding Agent Control Center + +> 一个给 AI 编程 Agent 用的"任务调度中心"。人类派发任务,AI Agent 领任务、拆步骤、执行、汇报,全程可视化。 + +## 一键启动 + +```bash +pip3 install fastapi uvicorn websockets +python3 server.py +# 打开 http://localhost:5016 +``` + +端口默认 5016,可通过 `OPSPILOT_PORT` 环境变量修改。 + +## 概念模型 + +``` +人类(你) OpsPilot(控制中心) Claude Code(tmux里) + │ │ │ + ├─ 新建项目+派发任务 ──→ │ │ + │ ├─ 创建 tmux session ────→ │ + │ ├─ 发送任务指令 ──────────→ │ + │ │ ├─ 拆步骤 + │ │ ├─ 写代码 + │ │ ├─ 弹确认框 + │ │ ← 检测到确认弹窗 ──────── │ + │ ← 显示确认按钮 ─────── │ │ + ├─ 点击确认 ────────────→ │ ──→ 发送确认 ──────────→ │ + │ │ ├─ 继续执行 + │ │ ← 步骤完成通知 ────────── │ + │ ← 步骤进度更新 ─────── │ │ + │ │ ├─ 全部完成 +``` + +## 给 AI Agent 的启动说明 + +如果你是一个 AI Coding Agent(比如 Claude Code),进入这个项目后: + +1. 阅读本文件了解项目结构 +2. `python3 server.py` 启动控制中心 +3. 控制中心提供 Web 仪表盘 + REST API + WebSocket +4. 通过 `/api/tmux/session` 创建新的 tmux 会话并启动另一个 Claude Code 实例 +5. 通过 `/api/tmux/send` 向 tmux 中的 Agent 发送指令 +6. 通过 `/api/tmux/capture` 捕获 Agent 的输出 +7. 通过 `/api/tasks` 管理任务和步骤进度 + +## 项目结构 + +``` +OpsPilot/ +├── server.py # FastAPI 主程序 (仪表盘 + API + WebSocket) +├── frontend/ +│ └── dashboard.html # 单页 Web 仪表盘 +├── config.json # 用户配置 (可选,自动生成) +├── start.sh # 启动脚本 +├── state/ # 运行时状态 (自动生成) +│ ├── agents.json # Agent 注册表 +│ ├── projects.json # 项目注册表 +│ └── tasks.json # 任务和步骤 +├── logs/ # 事件日志 +└── CLAUDE.md # 本文件 +``` + +## 核心 API + +| 端点 | 方法 | 用途 | +|------|------|------| +| `/` | GET | Web 仪表盘 | +| `/ws` | WS | 实时状态推送 (每 10s) | +| `/api/state` | GET | 当前完整状态 | +| `/api/projects` | POST | 注册/更新项目 | +| `/api/agents` | POST | 注册/更新 Agent | +| `/api/tasks` | POST | 创建任务 (含步骤列表) | +| `/api/tasks/{id}/start` | POST | 启动任务 | +| `/api/tasks/{id}/steps/{n}/complete` | POST | 完成一个步骤 | +| `/api/tmux/session` | POST | 创建 tmux 会话 + 启动 Claude Code | +| `/api/tmux/send` | POST | 向 tmux 会话发送按键 | +| `/api/tmux/confirm` | POST | 发送确认 (yes/no/enter/escape) | +| `/api/tmux/capture` | POST | 捕获 tmux 窗格内容 | +| `/api/tmux/live/{session}` | GET | 获取实时终端内容 | +| `/api/fs/list` | GET | 浏览目录 (路径选择器) | + +## 如何让 AI Agent 管理多个项目 + +1. 在仪表盘点 "+ New" 或调用 `/api/projects` 创建项目 +2. 系统自动创建对应的 tmux session 并在其中启动 Claude Code +3. 通过仪表盘的终端面板或 API 向特定项目的 Agent 发送指令 +4. 每个项目独立运行,互不干扰 +5. 仪表盘左侧显示所有项目,点击切换查看 + +## 配置 + +可选的环境变量: +- `OPSPILOT_PORT`: Web 服务端口 (默认 5016) +- `OPSPILOT_PROJECTS_DIR`: 项目默认存放目录 (默认 ~/Documents/Projects) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c34dfb3 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# OpsPilot — AI Coding Agent Control Center + +一个**给 AI 编程 Agent 用的任务调度中心**。人类派发开发任务,AI Agent 自动拆步骤、执行、汇报,全程可视化。 + +## 为什么需要 OpsPilot? + +如果你在用 Claude Code / Cursor / Codex 等 AI 编程工具管理多个项目,你会遇到这些问题: + +- 每个项目要手动开 tmux、手动启动 AI Agent +- AI Agent 执行到一半弹确认框,你得切到对应终端去点 +- 多个项目并行开发,不知道各自进度 +- 任务步骤不透明,不知道 Agent 在做什么 + +OpsPilot 解决的就是这些——一个 Web 控制台,同时管理多个 AI 编程 Agent。 + +## 快速开始 + +```bash +# 1. 克隆 +git clone OpsPilot +cd OpsPilot + +# 2. 安装依赖 (只需要 FastAPI) +pip3 install fastapi uvicorn websockets + +# 3. 启动 +python3 server.py + +# 4. 打开浏览器 +open http://localhost:5016 +``` + +> 前置要求:Python 3.9+、tmux、Claude Code CLI (`claude` 命令可用) + +## 使用方式 + +### 方式一:Web 仪表盘(推荐) + +打开 `http://localhost:5016`,在 Web 界面上: +1. 点 **+ New** 创建项目 +2. 用目录浏览器选择项目路径 +3. 输入任务描述 +4. 系统自动创建 tmux 会话、启动 Claude Code、拆步骤执行 +5. 遇到确认弹窗直接在 Web 上点击确认 + +### 方式二:API 调用 + +```bash +# 创建项目 +curl -X POST http://localhost:5016/api/projects \ + -H 'Content-Type: application/json' \ + -d '{"name":"MyProject","path":"/path/to/project"}' + +# 派发任务 +curl -X POST http://localhost:5016/api/tasks \ + -H 'Content-Type: application/json' \ + -d '{"project":"MyProject","title":"Build a REST API","steps":["DB schema","API routes","Tests","Deploy"]}' +``` + +### 方式三:让 AI Agent 自己管理 + +如果你在别的项目里用 Claude Code,可以让它同时管理 OpsPilot: + +> 用 API 调 OpsPilot 创建新项目,然后在 tmux 里启动一个 Claude Code 实例去写代码,进度回报给 OpsPilot。 + +## 架构 + +``` +┌─────────────────────────────────────────────────┐ +│ OpsPilot :5016 │ +│ ┌──────────┬──────────────┬─────────────────┐ │ +│ │ Projects │ Task Steps │ Live Terminal │ │ +│ │ 列表 │ 步骤进度 │ + 确认按钮 │ │ +│ └──────────┴──────────────┴─────────────────┘ │ +│ REST API + WebSocket │ +└─────────────────────┬───────────────────────────┘ + │ tmux send-keys / capture-pane + ┌───────────┼───────────┐ + ▼ ▼ ▼ + ┌─────────┐ ┌─────────┐ ┌─────────┐ + │ tmux │ │ tmux │ │ tmux │ + │ session │ │ session │ │ session │ + │ proj-1 │ │ proj-2 │ │ proj-3 │ + │ Claude │ │ Claude │ │ Claude │ + │ Code │ │ Code │ │ Code │ + └─────────┘ └─────────┘ └─────────┘ +``` + +## 配置 + +环境变量(可选): + +| 变量 | 默认值 | 说明 | +|------|--------|------| +| `OPSPILOT_PORT` | `5016` | Web 服务端口 | +| `OPSPILOT_PROJECTS_DIR` | `~/Documents/Projects` | 项目默认目录 | + +## License + +MIT diff --git a/frontend/dashboard.html b/frontend/dashboard.html new file mode 100644 index 0000000..af9208a --- /dev/null +++ b/frontend/dashboard.html @@ -0,0 +1,645 @@ + + + + + +OpsPilot - Task Control Center + + + +
+
+ + Connecting... +
+
+ 🖥 0 Agents + 📁 0 Projects + 📋 0 Tasks + --:--:-- +
+
+ +
+ +
+
+ 📁 Projects + +
+
+
暂无项目
+
+
+ + +
+
+ 📋 Task Progress +
+ + +
+
+
+
+

选择一个项目

+

在左侧项目列表中点击一个项目,
查看任务执行进度和步骤详情。

+
+
+
+ + +
+
+ 🖥 Terminal & Controls + +
+
+
+
选择项目后显示终端输出...
+
+ + + + + +
+
+ + +
+
+
+
+ + + + + + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c60e16 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.100.0 +uvicorn>=0.30.0 +websockets>=12.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..fc184d5 --- /dev/null +++ b/server.py @@ -0,0 +1,584 @@ +""" +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("

OpsPilot Dashboard

dashboard.html not found

") + + +@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") diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..d23c324 --- /dev/null +++ b/start.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# OpsPilot Startup Script +# Usage: ./start.sh [port] + +PORT=${1:-${OPSPILOT_PORT:-5016}} + +echo "============================================" +echo " OpsPilot - AI Coding Agent Control Center" +echo "============================================" +echo "" +echo " Starting on port $PORT ..." +echo " Dashboard: http://localhost:$PORT" +echo "" +echo " Requirements:" +echo " - Python 3.9+" +echo " - tmux" +echo " - Claude Code CLI (optional, for agents)" +echo "" + +# Check Python +python3 --version > /dev/null 2>&1 || { echo "❌ Python 3 required"; exit 1; } + +# Check tmux +tmux -V > /dev/null 2>&1 || { echo "⚠️ tmux not found (required for agent control)"; } + +# Install dependencies if needed +pip3 install -q fastapi uvicorn websockets 2>/dev/null + +# Ensure directories +mkdir -p state logs + +# Start server +export OPSPILOT_PORT=$PORT +python3 server.py