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:
2026-05-29 14:52:45 +08:00
co-authored by Claude Opus 4.7
commit 92670cd667
7 changed files with 1467 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
__pycache__/
*.pyc
logs/
state/
.env
.DS_Store
+95
View File
@@ -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)
+100
View File
@@ -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
+645
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.100.0
uvicorn>=0.30.0
websockets>=12.0
+584
View File
@@ -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")
Executable
+34
View File
@@ -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