mirror of
https://github.com/wu736139669/OpsPilot.git
synced 2026-08-05 06:23:35 +00:00
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>
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
logs/
|
||||
state/
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -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)
|
||||
@@ -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 <repo-url> 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
|
||||
@@ -0,0 +1,645 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>OpsPilot - Task Control Center</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f8fb; --bg-card: #ffffff; --bg-hover: #f0f2f6;
|
||||
--border: #e2e6ed; --border-light: #eef0f4;
|
||||
--text: #1a1d2e; --text-secondary: #6b7185; --text-muted: #9ba0b2;
|
||||
--accent: #4f6ef7; --accent-light: #eef1fd;
|
||||
--green: #22c55e; --green-bg: #e6f7ed;
|
||||
--amber: #f59e0b; --amber-bg: #fffbeb; --amber-border: #fcd34d;
|
||||
--red: #ef4444; --red-bg: #fde8e8;
|
||||
--purple: #8b5cf6; --purple-bg: #f3f0ff;
|
||||
--terminal-bg: #0d1117; --terminal-text: #c9d1d9;
|
||||
--radius: 8px; --radius-sm: 5px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: var(--text); height: 100vh; display: flex; flex-direction: column; }
|
||||
|
||||
/* Header */
|
||||
.header { background: var(--bg-card); border-bottom: 1px solid var(--border); padding: 0 20px; height: 48px; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.logo { font-weight: 700; font-size: 15px; letter-spacing: -0.3px; }
|
||||
.logo span { color: var(--accent); }
|
||||
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
|
||||
.dot.live { background: var(--green); animation: pulse 2s infinite; }
|
||||
.dot.dead { background: var(--red); animation: none; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; font-size: 11px; color: var(--text-secondary); }
|
||||
.header-right strong { color: var(--text); }
|
||||
|
||||
/* Main 3-column layout */
|
||||
.main { display: grid; grid-template-columns: 240px 1fr 380px; flex: 1; overflow: hidden; }
|
||||
.col { display: flex; flex-direction: column; border-right: 1px solid var(--border); overflow: hidden; }
|
||||
.col:last-child { border-right: none; }
|
||||
.col-header { padding: 12px 16px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); border-bottom: 1px solid var(--border-light); flex-shrink: 0; display: flex; justify-content: space-between; align-items: center; }
|
||||
.col-body { flex: 1; overflow-y: auto; padding: 8px; }
|
||||
|
||||
/*── Left: Projects ──*/
|
||||
.project-item {
|
||||
padding: 10px 12px; border-radius: var(--radius-sm); cursor: pointer;
|
||||
border: 1px solid transparent; margin-bottom: 4px; transition: all 0.15s;
|
||||
}
|
||||
.project-item:hover { background: var(--bg-hover); border-color: var(--border); }
|
||||
.project-item.active { background: var(--accent-light); border-color: var(--accent); }
|
||||
.project-item .pname { font-weight: 600; font-size: 12px; }
|
||||
.project-item .pmeta { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
|
||||
.project-item .ptask { font-size: 10px; color: var(--accent); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.project-item.needs-attention { border-color: var(--amber); box-shadow: 0 0 0 2px rgba(245,158,11,0.2); animation: attentionPulse 2s infinite; }
|
||||
@keyframes attentionPulse { 0%,100% { box-shadow: 0 0 0 2px rgba(245,158,11,0.2); } 50% { box-shadow: 0 0 0 4px rgba(245,158,11,0.35); } }
|
||||
.status-tag { display: inline-block; font-size: 9px; padding: 1px 6px; border-radius: 8px; font-weight: 500; }
|
||||
.tag-running { background: var(--accent-light); color: var(--accent); }
|
||||
.tag-completed { background: var(--green-bg); color: var(--green); }
|
||||
.tag-queued { background: #f3f4f6; color: var(--text-muted); }
|
||||
.tag-warning { background: var(--amber-bg); color: var(--amber); }
|
||||
|
||||
/*── Center: Task Steps ──*/
|
||||
.task-empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||
.task-empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text-secondary); }
|
||||
.task-empty p { font-size: 12px; line-height: 1.6; }
|
||||
.task-header { padding: 4px 0 12px; border-bottom: 1px solid var(--border-light); margin-bottom: 8px; }
|
||||
.task-title { font-size: 15px; font-weight: 600; }
|
||||
.task-status-line { font-size: 11px; color: var(--text-muted); margin-top: 4px; }
|
||||
.step-list { display: flex; flex-direction: column; gap: 0; }
|
||||
.step-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px 8px; position: relative; }
|
||||
.step-item:not(:last-child)::before {
|
||||
content: ''; position: absolute; left: 19px; top: 36px; bottom: -4px;
|
||||
width: 2px; background: var(--border);
|
||||
}
|
||||
.step-item.completed:not(:last-child)::before { background: var(--green); }
|
||||
.step-item.running:not(:last-child)::before { background: var(--accent); }
|
||||
.step-icon {
|
||||
width: 28px; height: 28px; border-radius: 50%; display: flex; align-items: center;
|
||||
justify-content: center; font-size: 12px; flex-shrink: 0; position: relative; z-index: 1;
|
||||
border: 2px solid var(--border); background: var(--bg-card); color: var(--text-muted);
|
||||
}
|
||||
.step-item.completed .step-icon { border-color: var(--green); background: var(--green-bg); color: var(--green); }
|
||||
.step-item.running .step-icon { border-color: var(--accent); background: var(--accent-light); color: var(--accent); animation: stepPulse 1.5s infinite; }
|
||||
.step-item.failed .step-icon { border-color: var(--red); background: var(--red-bg); color: var(--red); }
|
||||
@keyframes stepPulse { 0%,100% { box-shadow: 0 0 0 0 rgba(79,110,247,0.3) } 50% { box-shadow: 0 0 0 6px rgba(79,110,247,0) } }
|
||||
.step-info { flex: 1; min-width: 0; }
|
||||
.step-name { font-size: 12px; font-weight: 500; }
|
||||
.step-item.completed .step-name { color: var(--text-secondary); text-decoration: line-through; text-decoration-color: var(--green); }
|
||||
.step-item.running .step-name { color: var(--accent); font-weight: 600; }
|
||||
.step-duration { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
|
||||
.step-item.running .step-duration { color: var(--accent); }
|
||||
.step-running-indicator { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); margin-left: 6px; animation: pulse 1.5s infinite; }
|
||||
|
||||
/*── Right: Terminal + Actions ──*/
|
||||
.terminal-wrap { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.terminal-box {
|
||||
flex: 1; background: var(--terminal-bg); color: var(--terminal-text);
|
||||
font-family: 'SF Mono', 'Menlo', monospace; font-size: 11px; line-height: 1.45;
|
||||
padding: 10px; white-space: pre-wrap; overflow-y: auto; border-radius: var(--radius-sm);
|
||||
min-height: 150px;
|
||||
}
|
||||
.prompt-bar {
|
||||
background: var(--amber-bg); border: 1px solid var(--amber-border); border-radius: var(--radius-sm);
|
||||
padding: 10px 14px; margin-bottom: 8px; animation: flashBorder 2s ease-in-out;
|
||||
}
|
||||
@keyframes flashBorder { 0%,100%{border-color:var(--amber-border)} 50%{border-color:var(--amber)} }
|
||||
.prompt-question { font-size: 11px; font-weight: 600; color: #92400e; margin-bottom: 6px; }
|
||||
.prompt-btns { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.prompt-btn {
|
||||
padding: 5px 14px; border-radius: 4px; border: none; font-size: 11px; font-weight: 600;
|
||||
font-family: inherit; cursor: pointer; transition: all 0.15s;
|
||||
}
|
||||
.pbtn-yes { background: var(--green); color: #fff; }
|
||||
.pbtn-allow { background: var(--purple); color: #fff; }
|
||||
.pbtn-no { background: var(--red); color: #fff; }
|
||||
.pbtn-esc { background: #e5e7eb; color: var(--text-secondary); }
|
||||
.action-row { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
|
||||
.act-btn {
|
||||
padding: 5px 12px; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--bg-card); color: var(--text-secondary); cursor: pointer;
|
||||
font-size: 11px; font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.act-btn:hover { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
|
||||
.act-btn.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.act-btn.primary:hover { background: #3d5de7; }
|
||||
.task-input-row { display: flex; gap: 6px; margin-top: 8px; }
|
||||
.task-input-row input {
|
||||
flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 4px;
|
||||
font-size: 11px; font-family: inherit; background: var(--bg); color: var(--text);
|
||||
}
|
||||
.task-input-row input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-light); }
|
||||
|
||||
/* Toast */
|
||||
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--text); color: #fff; padding: 10px 18px; border-radius: var(--radius-sm); font-size: 12px; z-index: 300; animation: slideIn 0.2s ease-out; }
|
||||
@keyframes slideIn { from { transform: translateY(10px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-thumb { background: #d1d5db; border-radius: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="logo">⚡ <span>OpsPilot</span></div>
|
||||
<span><span class="dot live" id="live-dot"></span> <span id="conn-text" style="font-size:11px;color:var(--text-muted);">Connecting...</span></span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span>🖥 <strong id="stat-agents">0</strong> Agents</span>
|
||||
<span>📁 <strong id="stat-projects">0</strong> Projects</span>
|
||||
<span>📋 <strong id="stat-tasks">0</strong> Tasks</span>
|
||||
<span id="stat-time">--:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- LEFT: Projects -->
|
||||
<div class="col">
|
||||
<div class="col-header">
|
||||
📁 Projects
|
||||
<button class="act-btn" style="font-size:10px;padding:3px 8px;" onclick="showNewProject()">+ New</button>
|
||||
</div>
|
||||
<div class="col-body" id="project-list">
|
||||
<div style="text-align:center;padding:40px 16px;color:var(--text-muted);font-size:12px;">暂无项目</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CENTER: Task Steps -->
|
||||
<div class="col">
|
||||
<div class="col-header">
|
||||
📋 Task Progress
|
||||
<div style="display:flex;align-items:center;gap:8px;">
|
||||
<span style="font-size:10px;font-weight:400;color:var(--text-muted);" id="task-badge"></span>
|
||||
<button id="btn-close-task" onclick="deselectProject()" style="display:none;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:14px;padding:0 6px;color:var(--text-muted);line-height:20px;" title="关闭">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-body" id="task-detail">
|
||||
<div class="task-empty">
|
||||
<h3>选择一个项目</h3>
|
||||
<p>在左侧项目列表中点击一个项目,<br>查看任务执行进度和步骤详情。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: Terminal + Controls -->
|
||||
<div class="col">
|
||||
<div class="col-header">
|
||||
🖥 Terminal & Controls
|
||||
<span style="font-size:10px;font-weight:400;color:var(--text-muted);" id="term-label"></span>
|
||||
</div>
|
||||
<div class="col-body" style="display:flex;flex-direction:column;">
|
||||
<div id="prompt-area"></div>
|
||||
<div class="terminal-box" id="terminal">选择项目后显示终端输出...</div>
|
||||
<div class="action-row" id="action-bar">
|
||||
<button class="act-btn" onclick="confirmAgent('escape')">Esc</button>
|
||||
<button class="act-btn" onclick="confirmAgent('enter')">↵ 回车</button>
|
||||
<button class="act-btn" onclick="confirmAgent('yes')">✅ 确认(1)</button>
|
||||
<button class="act-btn" onclick="confirmAgent('yes_allow_all')">🔓 全部允许(2)</button>
|
||||
<button class="act-btn" onclick="refreshTerminal()">↻ 刷新终端</button>
|
||||
</div>
|
||||
<div class="task-input-row">
|
||||
<input type="text" id="agent-prompt" placeholder="输入指令发送给 Claude Code..."
|
||||
onkeydown="if(event.key==='Enter')sendPrompt()">
|
||||
<button class="act-btn primary" onclick="sendPrompt()">发送</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Project Modal -->
|
||||
<div id="modal-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:200;align-items:center;justify-content:center;">
|
||||
<div style="background:var(--bg-card);border-radius:12px;padding:24px;width:440px;box-shadow:0 20px 60px rgba(0,0,0,0.15);">
|
||||
<h3 style="font-size:16px;margin-bottom:16px;">新建项目 + 派发任务</h3>
|
||||
<div style="margin-bottom:10px;">
|
||||
<label style="font-size:11px;color:var(--text-muted);">项目名称</label>
|
||||
<input type="text" id="new-proj-name" placeholder="例如:Odineye" style="width:100%;padding:8px;border:1px solid var(--border);border-radius:4px;font-size:13px;font-family:inherit;margin-top:4px;">
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<label style="font-size:11px;color:var(--text-muted);">项目路径 <span style="color:var(--accent);cursor:pointer;float:right;" onclick="browsePath('')">📂 浏览...</span></label>
|
||||
<div id="path-breadcrumb" style="font-size:10px;color:var(--text-muted);margin:4px 0;min-height:16px;"></div>
|
||||
<input type="text" id="new-proj-path" placeholder="选择或输入路径..." style="width:100%;padding:8px;border:1px solid var(--border);border-radius:4px;font-size:12px;font-family:inherit;margin-top:2px;">
|
||||
<div id="path-picker" style="max-height:150px;overflow-y:auto;border:1px solid var(--border);border-radius:4px;margin-top:4px;display:none;background:var(--bg-card);"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:16px;">
|
||||
<label style="font-size:11px;color:var(--text-muted);">任务描述(Claude Code 会自动拆分为步骤)</label>
|
||||
<textarea id="new-task-desc" rows="3" placeholder="例如:按照 SPEC.md 构建完整的 Odin's Eye 项目..." style="width:100%;padding:8px;border:1px solid var(--border);border-radius:4px;font-size:12px;font-family:inherit;margin-top:4px;resize:vertical;"></textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;">
|
||||
<button class="act-btn" onclick="closeModal()">取消</button>
|
||||
<button class="act-btn primary" onclick="createProjectAndTask()">创建项目并启动</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let ws, state = {}, selectedProject = null;
|
||||
const WS = `ws://${location.host}/ws`;
|
||||
|
||||
function connect() {
|
||||
ws = new WebSocket(WS);
|
||||
ws.onopen = () => {
|
||||
document.getElementById('conn-text').textContent = 'Connected';
|
||||
document.getElementById('live-dot').className = 'dot live';
|
||||
};
|
||||
ws.onmessage = (e) => { state = JSON.parse(e.data); render(); };
|
||||
ws.onclose = () => {
|
||||
document.getElementById('conn-text').textContent = 'Disconnected';
|
||||
document.getElementById('live-dot').className = 'dot dead';
|
||||
setTimeout(connect, 3000);
|
||||
};
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: {'Content-Type': 'application/json'} };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
return (await fetch(path, opts)).json();
|
||||
}
|
||||
|
||||
// ── Render ──
|
||||
function render() {
|
||||
const projects = state.projects || {};
|
||||
const agents = state.agents || {};
|
||||
const tasks = state.tasks || {};
|
||||
const terminals = state.terminals || {};
|
||||
|
||||
document.getElementById('stat-agents').textContent = Object.keys(agents).length;
|
||||
document.getElementById('stat-projects').textContent = Object.keys(projects).length;
|
||||
document.getElementById('stat-tasks').textContent = Object.keys(tasks).length;
|
||||
|
||||
renderProjects(projects, agents, tasks);
|
||||
if (selectedProject) renderTaskDetail(projects, tasks, agents, terminals);
|
||||
}
|
||||
|
||||
function renderProjects(projects, agents, tasks) {
|
||||
const container = document.getElementById('project-list');
|
||||
const names = Object.keys(projects);
|
||||
if (names.length === 0) {
|
||||
container.innerHTML = '<div style="text-align:center;padding:40px 16px;color:var(--text-muted);font-size:12px;">暂无项目</div>';
|
||||
return;
|
||||
}
|
||||
// Find active task per project
|
||||
const projectActiveTask = {};
|
||||
Object.values(tasks).forEach(t => {
|
||||
const pn = t.project;
|
||||
if (!projectActiveTask[pn] || t.status === 'running') projectActiveTask[pn] = t;
|
||||
});
|
||||
|
||||
// Detect which projects need attention (pending confirmation in terminal)
|
||||
const terminals = state.terminals || {};
|
||||
const needingAttention = new Set();
|
||||
Object.entries(projects).forEach(([name, p]) => {
|
||||
const projAgents = (p.agents || []).map(n => (state.agents||{})[n]).filter(Boolean);
|
||||
const sessionName = projAgents[0]?.tmux_session || name.toLowerCase().replace(/\s+/g, '-');
|
||||
if (detectPrompt(terminals[sessionName]?.content)) needingAttention.add(name);
|
||||
});
|
||||
|
||||
container.innerHTML = names.map(name => {
|
||||
const p = projects[name];
|
||||
const task = projectActiveTask[name];
|
||||
let tagClass = 'tag-queued', tagText = '待命';
|
||||
if (task) {
|
||||
if (task.status === 'running') { tagClass = 'tag-running'; tagText = `步骤 ${(task.current_step||0)+1}/${task.steps?.length||0}`; }
|
||||
else if (task.status === 'completed') { tagClass = 'tag-completed'; tagText = '完成'; }
|
||||
}
|
||||
// Override if needs user confirmation
|
||||
if (needingAttention.has(name)) { tagClass = 'tag-warning'; tagText = '⚠ 需确认'; }
|
||||
const isActive = name === selectedProject;
|
||||
return `<div class="project-item ${isActive ? 'active' : ''} ${needingAttention.has(name) && !isActive ? 'needs-attention' : ''}" onclick="selectProject('${escapeHtml(name)}')">
|
||||
<div class="pname">📂 ${escapeHtml(name)} <span class="status-tag ${tagClass}">${tagText}</span></div>
|
||||
<div class="ptask">${task ? escapeHtml(task.title) : '暂无任务'}</div>
|
||||
<div class="pmeta">🤖 ${(p.agents||[]).length} agents · ${p.path ? '📁' : ''}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderTaskDetail(projects, tasks, agents, terminals) {
|
||||
const project = projects[selectedProject];
|
||||
if (!project) return;
|
||||
|
||||
// Find active task for this project
|
||||
const projectTasks = Object.values(tasks).filter(t => t.project === selectedProject);
|
||||
const activeTask = projectTasks.find(t => t.status === 'running') || projectTasks.sort((a,b) => b.created_at?.localeCompare(a.created_at||''))[0];
|
||||
|
||||
if (!activeTask) {
|
||||
document.getElementById('task-detail').innerHTML = `
|
||||
<div class="task-empty">
|
||||
<h3>📋 ${escapeHtml(selectedProject)} - 暂无任务</h3>
|
||||
<p>该项目还没有分配任务。<br>在右侧发送指令给 Claude Code,或点击"新建项目"来派发任务。</p>
|
||||
</div>`;
|
||||
document.getElementById('task-badge').textContent = '无任务';
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSteps = activeTask.steps?.length || 0;
|
||||
const currentStep = activeTask.current_step || 0;
|
||||
const completedSteps = activeTask.steps?.filter(s => s.status === 'completed').length || 0;
|
||||
const pct = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
|
||||
|
||||
document.getElementById('task-badge').textContent = `${completedSteps}/${totalSteps} · ${pct}%`;
|
||||
|
||||
let stepHtml = '';
|
||||
if (activeTask.status === 'running' || activeTask.status === 'completed') {
|
||||
stepHtml = (activeTask.steps || []).map((step, i) => {
|
||||
let icon = '○', cls = '';
|
||||
if (step.status === 'completed') { icon = '✓'; cls = 'completed'; }
|
||||
else if (step.status === 'running') { icon = '◉'; cls = 'running'; }
|
||||
else if (step.status === 'failed') { icon = '✕'; cls = 'failed'; }
|
||||
|
||||
let durationStr = '';
|
||||
if (step.status === 'running' && step.started_at) {
|
||||
const elapsed = Math.floor((Date.now() - new Date(step.started_at).getTime()) / 1000);
|
||||
durationStr = `⏱ 运行中 · ${formatDuration(elapsed)}`;
|
||||
} else if (step.duration_seconds > 0) {
|
||||
durationStr = `⏱ ${formatDuration(step.duration_seconds)}`;
|
||||
} else if (step.status === 'pending') {
|
||||
durationStr = '等待中';
|
||||
}
|
||||
|
||||
return `<div class="step-item ${cls}">
|
||||
<div class="step-icon">${icon}</div>
|
||||
<div class="step-info">
|
||||
<div class="step-name">${escapeHtml(step.name)}${step.status === 'running' ? '<span class="step-running-indicator"></span>' : ''}</div>
|
||||
<div class="step-duration">${durationStr}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} else {
|
||||
// Task queued - show steps without animation
|
||||
stepHtml = (activeTask.steps || []).map((step, i) => `
|
||||
<div class="step-item">
|
||||
<div class="step-icon">○</div>
|
||||
<div class="step-info">
|
||||
<div class="step-name">${escapeHtml(step.name)}</div>
|
||||
<div class="step-duration">等待开始</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
const statusLabel = activeTask.status === 'running' ? '🔄 执行中' : activeTask.status === 'completed' ? '✅ 已完成' : '⏳ 等待中';
|
||||
|
||||
document.getElementById('task-detail').innerHTML = `
|
||||
<div class="task-header">
|
||||
<div class="task-title">${escapeHtml(activeTask.title)}</div>
|
||||
<div class="task-status-line">
|
||||
${statusLabel} · ${totalSteps} 个步骤 ·
|
||||
创建于 ${new Date(activeTask.created_at).toLocaleString('zh-CN')}
|
||||
${activeTask.completed_at ? ` · 完成于 ${new Date(activeTask.completed_at).toLocaleString('zh-CN')}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-list">${stepHtml}</div>
|
||||
${activeTask.status === 'completed' ? '<div style="text-align:center;padding:16px;color:var(--green);font-size:13px;font-weight:600;">🎉 任务全部完成!请派发新任务。</div>' : ''}
|
||||
`;
|
||||
|
||||
// Terminal
|
||||
const projAgents = (project.agents || []).map(n => agents[n]).filter(Boolean);
|
||||
const sessionName = projAgents[0]?.tmux_session || selectedProject.toLowerCase().replace(/\s+/g, '-');
|
||||
document.getElementById('term-label').textContent = sessionName;
|
||||
const termContent = terminals[sessionName]?.content || '暂无终端数据';
|
||||
document.getElementById('terminal').textContent = termContent;
|
||||
document.getElementById('terminal').scrollTop = document.getElementById('terminal').scrollHeight;
|
||||
|
||||
// Prompt detection
|
||||
const promptInfo = detectPrompt(termContent);
|
||||
const promptArea = document.getElementById('prompt-area');
|
||||
if (promptInfo) {
|
||||
const hasAllowAll = termContent.includes('allow all edits') || termContent.includes('shift+tab');
|
||||
promptArea.innerHTML = `<div class="prompt-bar">
|
||||
<div class="prompt-question">⚠️ ${escapeHtml(promptInfo.question)}</div>
|
||||
<div class="prompt-btns">
|
||||
<button class="prompt-btn pbtn-yes" onclick="confirmAgent('yes')">✅ 确认</button>
|
||||
${hasAllowAll ? '<button class="prompt-btn pbtn-allow" onclick="confirmAgent(\'yes_allow_all\')">🔓 全部允许</button>' : ''}
|
||||
<button class="prompt-btn pbtn-no" onclick="confirmAgent('no')">❌ 拒绝</button>
|
||||
<button class="prompt-btn pbtn-esc" onclick="confirmAgent('escape')">Esc</button>
|
||||
</div>
|
||||
</div>`;
|
||||
} else {
|
||||
promptArea.innerHTML = '';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
function selectProject(name) {
|
||||
if (selectedProject === name) {
|
||||
// Clicking the active project deselects it
|
||||
deselectProject();
|
||||
return;
|
||||
}
|
||||
selectedProject = name;
|
||||
document.getElementById('btn-close-task').style.display = 'inline-block';
|
||||
render();
|
||||
if (ws) ws.send('ping');
|
||||
}
|
||||
|
||||
function deselectProject() {
|
||||
selectedProject = null;
|
||||
document.getElementById('btn-close-task').style.display = 'none';
|
||||
document.getElementById('task-detail').innerHTML = `
|
||||
<div class="task-empty">
|
||||
<h3>选择一个项目</h3>
|
||||
<p>在左侧项目列表中点击一个项目,<br>查看任务执行进度和步骤详情。</p>
|
||||
</div>`;
|
||||
document.getElementById('task-badge').textContent = '';
|
||||
document.getElementById('terminal').textContent = '选择项目后显示终端输出...';
|
||||
document.getElementById('prompt-area').innerHTML = '';
|
||||
renderProjects(state.projects||{}, state.agents||{}, state.tasks||{});
|
||||
}
|
||||
|
||||
function showNewProject() {
|
||||
document.getElementById('modal-overlay').style.display = 'flex';
|
||||
document.getElementById('new-proj-name').focus();
|
||||
// Default path
|
||||
const defaultPath = '/Users/wusumac/Documents/Projects';
|
||||
document.getElementById('new-proj-path').value = defaultPath;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('modal-overlay').style.display = 'none';
|
||||
document.getElementById('path-picker').style.display = 'none';
|
||||
}
|
||||
|
||||
async function browsePath(dirPath) {
|
||||
const path = dirPath || document.getElementById('new-proj-path').value || '/Users/wusumac/Documents/Projects';
|
||||
const r = await fetch(`/api/fs/list?path=${encodeURIComponent(path)}`);
|
||||
const d = await r.json();
|
||||
if (d.error) { toast('无法访问该目录'); return; }
|
||||
|
||||
document.getElementById('new-proj-path').value = d.path;
|
||||
|
||||
// Breadcrumb
|
||||
const parts = d.path.split('/').filter(Boolean);
|
||||
let bcHtml = '';
|
||||
let built = '';
|
||||
parts.forEach((p, i) => {
|
||||
built += '/' + p;
|
||||
const isLast = i === parts.length - 1;
|
||||
bcHtml += isLast
|
||||
? `<span style="color:var(--text);">/${p}</span>`
|
||||
: `<span style="cursor:pointer;color:var(--accent);" onclick="browsePath('${built}')">/${p}</span>`;
|
||||
});
|
||||
document.getElementById('path-breadcrumb').innerHTML = bcHtml || '/';
|
||||
|
||||
// Folder list
|
||||
const picker = document.getElementById('path-picker');
|
||||
picker.style.display = 'block';
|
||||
if (d.dirs.length === 0) {
|
||||
picker.innerHTML = '<div style="padding:8px;font-size:11px;color:var(--text-muted);text-align:center;">此目录下没有子文件夹</div>';
|
||||
} else {
|
||||
picker.innerHTML = d.dirs.map(dir => `
|
||||
<div style="padding:6px 10px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--border-light);"
|
||||
onclick="browsePath('${escapeHtml(dir.path)}')"
|
||||
onmouseover="this.style.background='var(--bg-hover)'"
|
||||
onmouseout="this.style.background=''">
|
||||
📁 ${escapeHtml(dir.name)}
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function createProjectAndTask() {
|
||||
const name = document.getElementById('new-proj-name').value.trim();
|
||||
const path = document.getElementById('new-proj-path').value.trim();
|
||||
const desc = document.getElementById('new-task-desc').value.trim();
|
||||
if (!name) return;
|
||||
|
||||
// Auto-append project name to selected path
|
||||
const baseDir = path || '/Users/wusumac/Documents/Projects';
|
||||
const projectDir = baseDir.endsWith(name) ? baseDir : `${baseDir}/${name}`;
|
||||
document.getElementById('new-proj-path').value = projectDir;
|
||||
|
||||
// Create project
|
||||
await api('POST', '/api/projects', {
|
||||
name, path: projectDir, description: desc || name,
|
||||
kanban_status: 'in_progress', agents: [`${name.toLowerCase()}-dev`]
|
||||
});
|
||||
|
||||
// Create agent
|
||||
const sessionName = name.toLowerCase().replace(/\s+/g, '-');
|
||||
await api('POST', '/api/agents', {
|
||||
name: `${name.toLowerCase()}-dev`, project: name,
|
||||
tmux_session: sessionName, tmux_window: '0',
|
||||
status: 'starting', task: desc || 'New task'
|
||||
});
|
||||
|
||||
// Create tmux session
|
||||
await api('POST', '/api/tmux/session', {
|
||||
name: sessionName, project_dir: projectDir,
|
||||
prompt: desc || 'Hello'
|
||||
});
|
||||
|
||||
// Create task with steps - Claude Code will auto-generate steps
|
||||
const stepNames = desc ? extractStepsFromDescription(desc) : ['Analyze requirements', 'Design architecture', 'Implement core', 'Test', 'Deliver'];
|
||||
await api('POST', '/api/tasks', {
|
||||
project: name, title: desc || `Build ${name}`,
|
||||
description: desc, steps: stepNames
|
||||
});
|
||||
|
||||
// Find the task and start it
|
||||
setTimeout(async () => {
|
||||
const r = await fetch('/api/state'); const s = await r.json();
|
||||
const tasks = s.tasks || {};
|
||||
const projTasks = Object.entries(tasks).filter(([id,t]) => t.project === name);
|
||||
if (projTasks.length > 0) {
|
||||
await api('POST', `/api/tasks/${projTasks[0][0]}/start`);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
closeModal();
|
||||
selectedProject = name;
|
||||
toast(`项目 "${name}" 已创建并启动`);
|
||||
if (ws) ws.send('ping');
|
||||
}
|
||||
|
||||
function extractStepsFromDescription(desc) {
|
||||
// Simple heuristic: split by numbered items or newlines
|
||||
const lines = desc.split('\n').filter(l => l.trim());
|
||||
if (lines.length >= 3 && lines.every(l => /^\d+[\.\)]/.test(l.trim()))) {
|
||||
return lines.map(l => l.replace(/^\d+[\.\)]\s*/, '').trim());
|
||||
}
|
||||
// Default: generate reasonable steps
|
||||
const keywords = desc.toLowerCase();
|
||||
if (keywords.includes('build') || keywords.includes('构建')) {
|
||||
return ['分析需求和设计架构', '搭建数据模型和存储层', '实现核心业务逻辑', '构建 API 和前端界面', '集成测试和部署'];
|
||||
}
|
||||
return ['需求分析', '架构设计', '核心实现', '测试验证', '交付'];
|
||||
}
|
||||
|
||||
async function sendPrompt() {
|
||||
const input = document.getElementById('agent-prompt');
|
||||
const prompt = input.value.trim();
|
||||
if (!prompt || !selectedProject) return;
|
||||
|
||||
const project = state.projects?.[selectedProject];
|
||||
const agents = state.agents || {};
|
||||
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
|
||||
const session = projAgents[0]?.tmux_session || selectedProject.toLowerCase().replace(/\s+/g, '-');
|
||||
|
||||
await api('POST', '/api/tmux/send', { session, window: '0', keys: prompt });
|
||||
input.value = '';
|
||||
toast('指令已发送');
|
||||
setTimeout(refreshTerminal, 2000);
|
||||
}
|
||||
|
||||
async function confirmAgent(action) {
|
||||
const project = state.projects?.[selectedProject];
|
||||
const agents = state.agents || {};
|
||||
const projAgents = (project?.agents || []).map(n => agents[n]).filter(Boolean);
|
||||
const session = projAgents[0]?.tmux_session || selectedProject?.toLowerCase().replace(/\s+/g, '-') || 'odineye';
|
||||
|
||||
await api('POST', '/api/tmux/confirm', { session, action });
|
||||
toast(action === 'yes' || action === 'yes_allow_all' ? '✅ 已确认' : action === 'no' ? '❌ 已拒绝' : '已操作');
|
||||
setTimeout(refreshTerminal, 2000);
|
||||
}
|
||||
|
||||
async function refreshTerminal() { if (ws) ws.send('ping'); }
|
||||
|
||||
function detectPrompt(text) {
|
||||
if (!text) return null;
|
||||
const patterns = [
|
||||
/Do you want to proceed\?/,
|
||||
/Do you want to create/,
|
||||
/Do you want to (run|execute|install|delete|remove|modify|overwrite)/,
|
||||
/Is this a project you created/,
|
||||
/Quick safety check/,
|
||||
];
|
||||
for (const p of patterns) {
|
||||
if (p.test(text)) {
|
||||
const lines = text.split('\n');
|
||||
const qIdx = lines.findIndex(l => p.test(l));
|
||||
return { question: (lines[qIdx] || '').trim(), detected: true };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
|
||||
function escapeHtml(t) {
|
||||
if (!t) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = t;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast'; el.textContent = msg;
|
||||
document.body.appendChild(el);
|
||||
setTimeout(() => el.remove(), 2500);
|
||||
}
|
||||
|
||||
// Init
|
||||
connect();
|
||||
setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) ws.send('ping'); }, 8000);
|
||||
setInterval(() => {
|
||||
document.getElementById('stat-time').textContent = new Date().toLocaleTimeString('zh-CN');
|
||||
}, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.100.0
|
||||
uvicorn>=0.30.0
|
||||
websockets>=12.0
|
||||
@@ -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("<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")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user