docs: translate CLAUDE.md, README.md, and skill to English

Also adds session management APIs (kill/resume/window), auto-detection
of Claude Code completion marks, Claude session listing, and session
status indicators in the dashboard.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 16:00:59 +08:00
co-authored by Claude Opus 4.7
parent 92670cd667
commit 930138b1f6
4 changed files with 621 additions and 145 deletions
+70 -66
View File
@@ -1,95 +1,99 @@
# OpsPilot — AI Coding Agent Control Center
> 一个给 AI 编程 Agent 用的"任务调度中心"。人类派发任务,AI Agent 领任务、拆步骤、执行、汇报,全程可视化。
> A task orchestration center for AI coding agents. Humans assign tasks, AI agents break them into steps, execute, and report progress — all visualized in real time.
## 一键启动
## Quick Start
```bash
pip3 install fastapi uvicorn websockets
python3 server.py
# 打开 http://localhost:5016
# Open http://localhost:5016
```
端口默认 5016,可通过 `OPSPILOT_PORT` 环境变量修改。
Default port is 5016, configurable via `OPSPILOT_PORT` environment variable.
## 概念模型
## Concept Model
```
人类(你) OpsPilot(控制中心) Claude Code(tmux)
Human (You) OpsPilot (Control Center) Claude Code (in tmux)
│ │ │
├─ 新建项目+派发任务 ──→ │
│ ├─ 创建 tmux session ────→
│ ├─ 发送任务指令 ──────────→
├─ 拆步骤
├─ 写代码
├─ 弹确认框
│ │ ← 检测到确认弹窗 ────────
│ ← 显示确认按钮 ─────── │
├─ 点击确认 ────────────→ │ ──→ 发送确认 ──────────→
├─ 继续执行
│ │ ← 步骤完成通知 ────────── │
│ ← 步骤进度更新 ─────── │
├─ 全部完成
├─ Create project+task ───→ │
├─ Create tmux session ────────→ │
├─ Send task instructions ─────→ │
├─ Break into steps
├─ Write code
├─ Pop up confirmation
│ ← Detect confirmation ──────
│ ← Show confirm button ── │
├─ Click confirm ─────────→ │ ──→ Send confirmation ───────→ │
├─ Continue
│ ← Step completion notice ───
│ ← Step progress update ─ │
├─ All done
```
## AI Agent 的启动说明
## For AI Agents
如果你是一个 AI Coding Agent(比如 Claude Code),进入这个项目后:
If you are an AI Coding Agent (e.g., Claude Code) entering this project:
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` 管理任务和步骤进度
1. Read this file to understand the project structure
2. Run `python3 server.py` to start the control center
3. The control center provides a Web dashboard + REST API + WebSocket
4. Use `/api/tmux/session` to create tmux sessions and launch separate Claude Code instances
5. Use `/api/tmux/send` to send commands to agents in tmux
6. Use `/api/tmux/capture` to capture agent output
7. Use `/api/tasks` to manage tasks and step progress
## 项目结构
## Project Structure
```
OpsPilot/
├── server.py # FastAPI 主程序 (仪表盘 + API + WebSocket)
├── server.py # FastAPI main (dashboard + API + WebSocket)
├── frontend/
│ └── dashboard.html # 单页 Web 仪表盘
├── config.json # 用户配置 (可选,自动生成)
├── start.sh # 启动脚本
├── state/ # 运行时状态 (自动生成)
│ ├── agents.json # Agent 注册表
── projects.json # 项目注册表
│ └── tasks.json # 任务和步骤
├── logs/ # 事件日志
└── CLAUDE.md # 本文件
│ └── dashboard.html # Single-page web dashboard
├── start.sh # Startup script
├── state/ # Runtime state (auto-generated)
│ ├── agents.json # Agent registry
│ ├── projects.json # Project registry
── tasks.json # Tasks and steps
├── logs/ # Event logs
├── CLAUDE.md # This file
└── README.md
```
## 核心 API
## Core 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 | 浏览目录 (路径选择器) |
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/` | GET | Web dashboard |
| `/ws` | WS | Real-time state push (every 10s) |
| `/api/state` | GET | Full current state |
| `/api/projects` | POST | Register/update project |
| `/api/agents` | POST | Register/update agent |
| `/api/tasks` | POST | Create task (with step list) |
| `/api/tasks/{id}/start` | POST | Start a task |
| `/api/tasks/{id}/steps/{n}/complete` | POST | Complete a step |
| `/api/tmux/session` | POST | Create tmux session + launch Claude Code |
| `/api/tmux/send` | POST | Send keystrokes to tmux session |
| `/api/tmux/confirm` | POST | Send confirmation (yes/no/enter/escape) |
| `/api/tmux/capture` | POST | Capture tmux pane content |
| `/api/tmux/kill` | POST | Kill a tmux session |
| `/api/tmux/resume` | POST | Resume or re-create a tmux session |
| `/api/tmux/window` | POST | Create new window in a session |
| `/api/tmux/live/{session}` | GET | Get live terminal content |
| `/api/claude/sessions` | GET | List Claude Code sessions for a project |
| `/api/fs/list` | GET | Browse directories (path picker) |
## 如何让 AI Agent 管理多个项目
## Managing Multiple Projects
1. 在仪表盘点 "+ New" 或调用 `/api/projects` 创建项目
2. 系统自动创建对应的 tmux session 并在其中启动 Claude Code
3. 通过仪表盘的终端面板或 API 向特定项目的 Agent 发送指令
4. 每个项目独立运行,互不干扰
5. 仪表盘左侧显示所有项目,点击切换查看
1. Click "+ New" in the dashboard or call `/api/projects` to create a project
2. The system auto-creates a tmux session and launches Claude Code
3. Send commands to specific project agents via the terminal panel or API
4. Each project runs independently without interference
5. The dashboard left sidebar shows all projects — click to switch
## 配置
## Configuration
可选的环境变量:
- `OPSPILOT_PORT`: Web 服务端口 (默认 5016)
- `OPSPILOT_PROJECTS_DIR`: 项目默认存放目录 (默认 ~/Documents/Projects)
Optional environment variables:
- `OPSPILOT_PORT`: Web server port (default: 5016)
- `OPSPILOT_PROJECTS_DIR`: Default project directory (default: ~/Documents/Projects)
+49 -39
View File
@@ -1,76 +1,74 @@
# OpsPilot — AI Coding Agent Control Center
一个**给 AI 编程 Agent 用的任务调度中心**。人类派发开发任务,AI Agent 自动拆步骤、执行、汇报,全程可视化。
A **task orchestration center for AI coding agents**. Assign development tasks, let AI agents break them into steps, execute, and report progress — all visualized in a web dashboard.
## 为什么需要 OpsPilot
## Why OpsPilot?
如果你在用 Claude Code / Cursor / Codex 等 AI 编程工具管理多个项目,你会遇到这些问题:
If you use Claude Code, Cursor, Codex, or other AI coding tools across multiple projects, you face these problems:
- 每个项目要手动开 tmux、手动启动 AI Agent
- AI Agent 执行到一半弹确认框,你得切到对应终端去点
- 多个项目并行开发,不知道各自进度
- 任务步骤不透明,不知道 Agent 在做什么
- Manually opening tmux sessions and launching AI agents for each project
- Switching terminals to approve confirmation prompts mid-execution
- No visibility into which project is doing what
- Opaque task progress — you don't know what step the agent is on
OpsPilot 解决的就是这些——一个 Web 控制台,同时管理多个 AI 编程 Agent。
OpsPilot solves all of this with a single web console for managing multiple AI coding agents simultaneously.
## 快速开始
## Quick Start
```bash
# 1. 克隆
git clone <repo-url> OpsPilot
# 1. Clone
git clone https://github.com/wu736139669/OpsPilot.git
cd OpsPilot
# 2. 安装依赖 (只需要 FastAPI)
# 2. Install dependencies (only FastAPI needed)
pip3 install fastapi uvicorn websockets
# 3. 启动
# 3. Start
python3 server.py
# 4. 打开浏览器
# 4. Open browser
open http://localhost:5016
```
> 前置要求:Python 3.9+tmuxClaude Code CLI (`claude` 命令可用)
> Prerequisites: Python 3.9+, tmux, Claude Code CLI (`claude` command available)
## 使用方式
## Usage
### 方式一:Web 仪表盘(推荐)
### Option 1: Web Dashboard (Recommended)
打开 `http://localhost:5016`,在 Web 界面上:
1. **+ New** 创建项目
2. 用目录浏览器选择项目路径
3. 输入任务描述
4. 系统自动创建 tmux 会话、启动 Claude Code、拆步骤执行
5. 遇到确认弹窗直接在 Web 上点击确认
Open `http://localhost:5016` and:
1. Click **+ New** to create a project
2. Use the directory browser to pick a project path
3. Enter a task description
4. The system auto-creates a tmux session, launches Claude Code, and tracks steps
5. Click confirm buttons directly in the browser when prompts appear
### 方式二:API 调用
### Option 2: API
```bash
# 创建项目
# Create a project
curl -X POST http://localhost:5016/api/projects \
-H 'Content-Type: application/json' \
-d '{"name":"MyProject","path":"/path/to/project"}'
# 派发任务
# Assign a task
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 自己管理
### Option 3: Let Another AI Agent Manage It
如果你在别的项目里用 Claude Code,可以让它同时管理 OpsPilot
If you're in a different project with Claude Code, you can have it manage OpsPilot via API — create projects, spawn agents, and track progress programmatically.
> 用 API 调 OpsPilot 创建新项目,然后在 tmux 里启动一个 Claude Code 实例去写代码,进度回报给 OpsPilot。
## 架构
## Architecture
```
┌─────────────────────────────────────────────────┐
│ OpsPilot :5016 │
│ ┌──────────┬──────────────┬─────────────────┐ │
│ │ Projects │ Task Steps │ Live Terminal │ │
│ │ 列表步骤进度 │ + 确认按钮 │ │
│ │ List+ Progress │ + Controls │ │
│ └──────────┴──────────────┴─────────────────┘ │
│ REST API + WebSocket │
└─────────────────────┬───────────────────────────┘
@@ -79,21 +77,33 @@ curl -X POST http://localhost:5016/api/tasks \
▼ ▼ ▼
┌─────────┐ ┌─────────┐ ┌─────────┐
│ tmux │ │ tmux │ │ tmux │
session │ │ session │ │ session │
Session │ │ Session │ │ Session │
│ proj-1 │ │ proj-2 │ │ proj-3 │
│ Claude │ │ Claude │ │ Claude │
│ Code │ │ Code │ │ Code │
└─────────┘ └─────────┘ └─────────┘
```
## 配置
## Features
环境变量(可选):
- **Multi-project management** — Run multiple Claude Code agents in parallel, each in its own tmux session
- **Task step tracking** — Auto-detect `✔` completion marks and update progress in real time
- **Remote confirmation** — Detect Claude Code permission prompts and let you approve from the browser
- **Live terminal view** — See agent output in real time with auto-refresh
- **Session management** — Resume, kill, or create new Claude Code sessions per project
- **Directory picker** — Browse and select project directories visually
- **Claude Code session history** — List and resume previous Claude Code sessions
| 变量 | 默认值 | 说明 |
|------|--------|------|
| `OPSPILOT_PORT` | `5016` | Web 服务端口 |
| `OPSPILOT_PROJECTS_DIR` | `~/Documents/Projects` | 项目默认目录 |
## API Reference
See [CLAUDE.md](CLAUDE.md) for the full API table.
## Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `OPSPILOT_PORT` | `5016` | Web server port |
| `OPSPILOT_PROJECTS_DIR` | `~/Documents/Projects` | Default projects directory |
## License
+289 -21
View File
@@ -33,10 +33,24 @@
.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; }
/* Main 3-column layout with resizable panels */
.main { display: flex; flex: 1; overflow: hidden; }
.col { display: flex; flex-direction: column; overflow: hidden; }
.col-left { width: 240px; flex-shrink: 0; }
.col-center { flex: 1; min-width: 200px; }
.col-right { width: 420px; min-width: 300px; flex-shrink: 0; }
/* Draggable splitter */
.splitter {
width: 5px; background: var(--border); cursor: col-resize; flex-shrink: 0;
position: relative; transition: background 0.15s;
}
.splitter:hover, .splitter.dragging { background: var(--accent); }
.splitter::after {
content: ''; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 3px; height: 24px; border-radius: 2px; background: transparent; transition: background 0.15s;
}
.splitter:hover::after, .splitter.dragging::after { background: rgba(255,255,255,0.6); }
.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; }
@@ -154,7 +168,7 @@
<div class="main">
<!-- LEFT: Projects -->
<div class="col">
<div class="col col-left">
<div class="col-header">
📁 Projects
<button class="act-btn" style="font-size:10px;padding:3px 8px;" onclick="showNewProject()">+ New</button>
@@ -164,8 +178,10 @@
</div>
</div>
<div class="splitter" id="splitter-1"></div>
<!-- CENTER: Task Steps -->
<div class="col">
<div class="col col-center">
<div class="col-header">
📋 Task Progress
<div style="display:flex;align-items:center;gap:8px;">
@@ -181,8 +197,10 @@
</div>
</div>
<div class="splitter" id="splitter-2"></div>
<!-- RIGHT: Terminal + Controls -->
<div class="col">
<div class="col col-right">
<div class="col-header">
🖥 Terminal & Controls
<span style="font-size:10px;font-weight:400;color:var(--text-muted);" id="term-label"></span>
@@ -193,9 +211,11 @@
<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>
<span id="confirm-btns" style="display:none;display:flex;gap:6px;">
<button class="act-btn" onclick="confirmAgent('yes')" style="background:var(--green);color:#fff;border-color:var(--green);">✅ 确认(1)</button>
<button class="act-btn" onclick="confirmAgent('yes_allow_all')" style="background:var(--purple);color:#fff;border-color:var(--purple);">🔓 全部允许(2)</button>
</span>
</div>
<div class="task-input-row">
<input type="text" id="agent-prompt" placeholder="输入指令发送给 Claude Code..."
@@ -206,6 +226,21 @@
</div>
</div>
<!-- New Task Modal -->
<div id="task-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;" onclick="if(event.target===this)closeTaskModal()">
<div style="background:var(--bg-card);border-radius:12px;padding:24px;width:500px;box-shadow:0 20px 60px rgba(0,0,0,0.15);">
<h3 style="font-size:16px;margin-bottom:12px;" id="task-modal-title">派发新任务</h3>
<div style="margin-bottom:12px;">
<label style="font-size:11px;color:var(--text-muted);">任务描述(Claude Code 会自动拆分为步骤执行)</label>
<textarea id="new-task-desc-input" rows="4" placeholder="例如:在现有项目里加一个用户登录功能,支持邮箱和密码注册..." style="width:100%;padding:10px;border:1px solid var(--border);border-radius:6px;font-size:13px;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="closeTaskModal()">取消</button>
<button class="act-btn primary" onclick="submitNewTask()">派发任务</button>
</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);">
@@ -215,10 +250,17 @@
<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>
<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 style="display:flex;gap:6px;align-items:center;">
<input type="text" id="new-proj-path" placeholder="选择或输入路径..." style="flex:1;padding:8px;border:1px solid var(--border);border-radius:4px;font-size:12px;font-family:inherit;">
<label style="font-size:10px;color:var(--text-muted);white-space:nowrap;display:flex;align-items:center;gap:3px;cursor:pointer;">
<input type="checkbox" id="use-existing-dir" onchange="onExistingDirToggle()"> 使用此目录
</label>
</div>
<div id="path-picker" style="max-height:160px;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>
@@ -304,8 +346,13 @@ function renderProjects(projects, agents, tasks) {
// Override if needs user confirmation
if (needingAttention.has(name)) { tagClass = 'tag-warning'; tagText = '⚠ 需确认'; }
const isActive = name === selectedProject;
// Session status indicator
const projAgents2 = (p.agents || []).map(n => (state.agents||{})[n]).filter(Boolean);
const tmuxRunning = projAgents2.some(a => a.tmux?.running);
const sessionDot = tmuxRunning ? '<span style="color:#3fb950;" title="tmux 在线">●</span>' : '<span style="color:#f85149;" title="tmux 离线">●</span>';
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="pname">${sessionDot} ${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>`;
@@ -388,7 +435,28 @@ function renderTaskDetail(projects, tasks, agents, terminals) {
</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>' : ''}
${activeTask.status === 'completed' ? '<div style="text-align:center;padding:16px;color:var(--green);font-size:13px;font-weight:600;">🎉 任务全部完成!</div>' : ''}
// Session status
const sessionRunning = projAgents.some(a => a.tmux?.running);
const claudeActive = termContent.includes('') && !termContent.includes('Welcome back');
let sessionHtml = '<div style="margin-top:12px;border-top:1px solid var(--border-light);padding-top:10px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">';
// Status pills
sessionHtml += `<span style="font-size:10px;display:flex;align-items:center;gap:4px;">`;
sessionHtml += `<span style="color:${sessionRunning ? '#3fb950' : '#f85149'};"></span> tmux: ${sessionRunning ? '线' : '线'}`;
sessionHtml += `</span>`;
sessionHtml += `<span style="font-size:10px;display:flex;align-items:center;gap:4px;">`;
sessionHtml += `<span style="color:${claudeActive ? '#3fb950' : '#8b949e'};">●</span> Claude: ${claudeActive ? '运行中' : (sessionRunning ? '待检测' : '未启动')}`;
sessionHtml += `</span>`;
sessionHtml += '<span style="flex:1;"></span>';
// Action buttons
sessionHtml += `<button class="act-btn primary" onclick="showNewTaskModal()">📋 派新任务</button>`;
sessionHtml += `<button class="act-btn" onclick="resumeSession()">🔄 恢复</button>`;
sessionHtml += `<input type="text" id="resume-session-id" placeholder="Session ID (可选)" style="width:100px;padding:3px 6px;border:1px solid var(--border);border-radius:4px;font-size:10px;font-family:monospace;">`;
sessionHtml += `<button class="act-btn danger" onclick="killSession()">⏹ 关闭</button>`;
sessionHtml += '</div>';
document.getElementById('task-detail').innerHTML += sessionHtml;
`;
// Terminal
@@ -399,8 +467,12 @@ function renderTaskDetail(projects, tasks, agents, terminals) {
document.getElementById('terminal').textContent = termContent;
document.getElementById('terminal').scrollTop = document.getElementById('terminal').scrollHeight;
// Prompt detection
// Show/hide confirm buttons based on prompt detection
const confirmBtns = document.getElementById('confirm-btns');
const promptInfo = detectPrompt(termContent);
if (confirmBtns) confirmBtns.style.display = promptInfo ? 'flex' : 'none';
// Prompt detection
const promptArea = document.getElementById('prompt-area');
if (promptInfo) {
const hasAllowAll = termContent.includes('allow all edits') || termContent.includes('shift+tab');
@@ -443,12 +515,14 @@ function deselectProject() {
document.getElementById('task-badge').textContent = '';
document.getElementById('terminal').textContent = '选择项目后显示终端输出...';
document.getElementById('prompt-area').innerHTML = '';
document.getElementById('confirm-btns').style.display = 'none';
renderProjects(state.projects||{}, state.agents||{}, state.tasks||{});
}
function showNewProject() {
document.getElementById('modal-overlay').style.display = 'flex';
document.getElementById('new-proj-name').focus();
document.getElementById('use-existing-dir').checked = false;
// Default path
const defaultPath = '/Users/wusumac/Documents/Projects';
document.getElementById('new-proj-path').value = defaultPath;
@@ -483,18 +557,41 @@ async function browsePath(dirPath) {
// 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>';
let html = '';
// Parent directory ".." entry (only if not at root)
if (d.path !== '/' && d.parent) {
html += `<div style="padding:6px 10px;cursor:pointer;font-size:12px;display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--border-light);color:var(--accent);font-weight:500;"
onclick="browsePath('${escapeHtml(d.parent)}')"
onmouseover="this.style.background='var(--accent-light)'"
onmouseout="this.style.background=''">
📂 ..
</div>`;
}
if (d.dirs.length === 0 && !html) {
html += '<div style="padding:8px;font-size:11px;color:var(--text-muted);text-align:center;">此目录下没有子文件夹</div>';
} else {
picker.innerHTML = d.dirs.map(dir => `
html += 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)}')"
ondblclick="browsePath('${escapeHtml(dir.path)}')"
onclick="selectDir('${escapeHtml(dir.path)}', '${escapeHtml(dir.name)}')"
onmouseover="this.style.background='var(--bg-hover)'"
onmouseout="this.style.background=''">
📁 ${escapeHtml(dir.name)}
</div>
`).join('');
}
picker.innerHTML = html;
}
function selectDir(path, name) {
document.getElementById('new-proj-path').value = path;
document.getElementById('new-proj-name').value = name;
document.getElementById('use-existing-dir').checked = true;
browsePath(path);
}
function onExistingDirToggle() {
// Visual feedback only - logic handled in createProjectAndTask
}
async function createProjectAndTask() {
@@ -503,9 +600,10 @@ async function createProjectAndTask() {
const desc = document.getElementById('new-task-desc').value.trim();
if (!name) return;
// Auto-append project name to selected path
// Auto-append project name unless "use existing" is checked
const useExisting = document.getElementById('use-existing-dir').checked;
const baseDir = path || '/Users/wusumac/Documents/Projects';
const projectDir = baseDir.endsWith(name) ? baseDir : `${baseDir}/${name}`;
const projectDir = useExisting ? baseDir : (baseDir.endsWith(name) ? baseDir : `${baseDir}/${name}`);
document.getElementById('new-proj-path').value = projectDir;
// Create project
@@ -634,6 +732,176 @@ function toast(msg) {
setTimeout(() => el.remove(), 2500);
}
// ── Session & Task management ──
function showNewTaskModal() {
if (!selectedProject) return;
document.getElementById('task-modal-title').textContent = '派发新任务 → ' + selectedProject;
document.getElementById('task-modal-overlay').style.display = 'flex';
document.getElementById('new-task-desc-input').focus();
}
function closeTaskModal() {
document.getElementById('task-modal-overlay').style.display = 'none';
}
async function submitNewTask() {
const desc = document.getElementById('new-task-desc-input').value.trim();
if (!desc || !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, '-');
const stepNames = extractStepsFromDescription(desc);
const r = await api('POST', '/api/tasks', { project: selectedProject, title: desc, description: desc, steps: stepNames });
const taskId = r.task?.id;
await api('POST', '/api/tmux/send', { session, window: '0', keys: desc });
if (projAgents[0]) {
await api('POST', `/api/agents/${projAgents[0].name}/status`, { status: 'running', task: desc });
}
if (taskId) {
setTimeout(async () => { await api('POST', `/api/tasks/${taskId}/start`); if (ws) ws.send('ping'); }, 1000);
}
closeTaskModal();
document.getElementById('new-task-desc-input').value = '';
toast(`新任务已派发给 ${selectedProject}`);
if (ws) ws.send('ping');
}
async function resumeSession() {
if (!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, '-');
const path = project?.path || '';
const resumeId = document.getElementById('resume-session-id')?.value?.trim() || '';
// Fetch available sessions and show picker
const r = await fetch(`/api/claude/sessions?project_dir=${encodeURIComponent(path)}`);
const data = await r.json();
const sessions = data.sessions || [];
if (resumeId) {
// Specific ID provided
const resp = await api('POST', '/api/tmux/resume', { session, project_dir: path, resume_id: resumeId });
toast(`已恢复会话 ${resumeId}`);
setTimeout(refreshTerminal, 2000);
} else if (sessions.length > 0) {
// Show session picker in the terminal bar
showSessionPicker(sessions, session, path);
} else {
// No sessions, just start fresh
const resp = await api('POST', '/api/tmux/resume', { session, project_dir: path, resume_id: '' });
toast('无历史会话,启动新 Claude Code');
setTimeout(refreshTerminal, 2000);
}
if (ws) ws.send('ping');
}
function showSessionPicker(sessions, sessionName, projectPath) {
const pickerHtml = `
<div class="prompt-bar" style="background:#eef1fd;border-color:var(--accent);">
<div class="prompt-question" style="color:var(--accent);">选择要恢复的 Claude Code 会话:</div>
<div style="max-height:180px;overflow-y:auto;margin-bottom:8px;">
${sessions.slice(0, 10).map(s => `
<div style="padding:6px 10px;cursor:pointer;font-size:11px;border-bottom:1px solid #e2e6ed;display:flex;justify-content:space-between;align-items:center;"
onclick="selectSession('${s.id}')"
onmouseover="this.style.background='#fff'"
onmouseout="this.style.background=''">
<span style="font-family:monospace;color:var(--accent);">${s.id.substring(0,12)}...</span>
<span style="font-size:10px;color:var(--text-muted);">${s.status} · ${s.updated_at ? new Date(s.updated_at).toLocaleString('zh-CN') : ''}</span>
</div>
`).join('')}
</div>
<button class="prompt-btn" style="background:var(--accent);color:#fff;border:none;" onclick="document.getElementById('prompt-area').innerHTML='';selectSession('')">启动新会话</button>
<button class="prompt-btn pbtn-esc" onclick="document.getElementById('prompt-area').innerHTML='';">取消</button>
</div>
`;
document.getElementById('prompt-area').innerHTML = pickerHtml;
}
async function selectSession(sessionId) {
if (!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, '-');
const path = project?.path || '';
document.getElementById('prompt-area').innerHTML = '';
if (sessionId) {
document.getElementById('resume-session-id').value = sessionId;
}
await api('POST', '/api/tmux/resume', { session, project_dir: path, resume_id: sessionId });
toast(sessionId ? `恢复会话 ${sessionId.substring(0,12)}...` : '启动新会话');
setTimeout(refreshTerminal, 2000);
if (ws) ws.send('ping');
}
async function killSession() {
if (!selectedProject) return;
if (!confirm('确定要关闭此项目的 tmux 会话吗?任务进度会保留。')) 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/kill', { session });
toast(`会话 ${session} 已关闭`);
if (ws) ws.send('ping');
}
// ── Splitter drag-to-resize ──
function initSplitter(splitterId, targetSelector, minWidth) {
const splitter = document.getElementById(splitterId);
if (!splitter) return;
let dragging = false, startX, startWidth;
splitter.addEventListener('mousedown', (e) => {
dragging = true;
startX = e.clientX;
const target = document.querySelector(targetSelector);
startWidth = target ? target.offsetWidth : 300;
splitter.classList.add('dragging');
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!dragging) return;
const dx = startX - e.clientX;
const target = document.querySelector(targetSelector);
if (!target) return;
// For left-column targets, reverse the drag direction
if (targetSelector === '.col-left') {
const newWidth = Math.max(minWidth, startWidth - dx);
target.style.width = newWidth + 'px';
} else {
const newWidth = Math.max(minWidth, startWidth + dx);
target.style.width = newWidth + 'px';
}
});
document.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
splitter.classList.remove('dragging');
document.body.style.cursor = '';
document.body.style.userSelect = '';
});
}
// splitter-1: drag to resize col-left (min 160px)
initSplitter('splitter-1', '.col-left', 160);
// splitter-2: drag to resize col-right (min 300px)
initSplitter('splitter-2', '.col-right', 300);
// Init
connect();
setInterval(() => { if (ws && ws.readyState === WebSocket.OPEN) ws.send('ping'); }, 8000);
+208 -14
View File
@@ -137,19 +137,7 @@ async def dashboard():
@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(),
}
return await _build_state()
@app.post("/api/agents")
@@ -354,6 +342,120 @@ async def update_task_status(task_id: str, data: dict):
# ── Tmux Control ──────────────────────────────────
@app.post("/api/tmux/kill")
async def kill_tmux_session(data: dict):
"""Kill a tmux session."""
session = data.get("session", "")
if not session:
raise HTTPException(status_code=400, detail="session required")
try:
subprocess.run(["tmux", "kill-session", "-t", session], capture_output=True, text=True, timeout=5)
add_log("info", "tmux", f"Session killed: {session}")
# Update agent status
agents = load_agents()
for name, a in agents.items():
if a.get("tmux_session") == session:
a["status"] = "stopped"
save_agents(agents)
await broadcast(await _build_state())
return {"status": "ok", "session": session}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/tmux/window")
async def create_tmux_window(data: dict):
"""Create a new window in an existing tmux session."""
session = data.get("session", "")
window_name = data.get("window_name", "dev2")
cwd = data.get("cwd", "")
if not session:
raise HTTPException(status_code=400, detail="session required")
try:
cmd = ["tmux", "new-window", "-t", session, "-n", window_name]
if cwd:
cmd += ["-c", cwd]
subprocess.run(cmd, capture_output=True, text=True, timeout=5)
add_log("info", "tmux", f"New window {window_name} in session {session}")
return {"status": "ok", "session": session, "window": window_name}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/tmux/resume")
async def resume_tmux_session(data: dict):
"""Resume or create a tmux session. If it exists, return status. If not, re-create it."""
session = data.get("session", "")
project_dir = data.get("project_dir", "")
if not session:
raise HTTPException(status_code=400, detail="session required")
# Ensure project dir exists
if project_dir:
os.makedirs(project_dir, exist_ok=True)
status = get_tmux_status(session)
if status["running"]:
return {"status": "already_running", "session": session, "tmux": status}
# Re-create the session
try:
subprocess.run(
["tmux", "new-session", "-d", "-s", session, "-n", "dev", "-c", project_dir or str(BASE_DIR)],
capture_output=True, text=True, timeout=5
)
time.sleep(1)
# Auto-start Claude Code with resume
resume_id = data.get("resume_id", "")
if resume_id:
claude_cmd = f"claude --resume {resume_id}"
else:
claude_cmd = "claude --resume"
subprocess.run(
["tmux", "send-keys", "-t", f"{session}:dev", claude_cmd, "Enter"],
capture_output=True, text=True, timeout=5
)
add_log("info", "tmux", f"Session {session} resumed, Claude Code starting with --resume")
return {"status": "resumed", "session": session, "tmux": get_tmux_status(session)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/claude/sessions")
async def list_claude_sessions(project_dir: str = ""):
"""List Claude Code sessions, optionally filtered by project directory."""
import glob as _glob
sessions_dir = os.path.expanduser("~/.claude/sessions")
sessions = []
try:
for f in sorted(_glob.glob(os.path.join(sessions_dir, "*.json")), reverse=True):
try:
data = json.loads(Path(f).read_text())
sid = data.get("sessionId", "")
cwd = data.get("cwd", "")
status = data.get("status", "unknown")
started = data.get("startedAt", "")
updated = data.get("updatedAt", "")
# Filter by project dir if specified
if project_dir and project_dir not in cwd:
continue
sessions.append({
"id": sid,
"cwd": cwd,
"status": status,
"started_at": started,
"updated_at": updated,
})
except Exception:
pass
return {"sessions": sessions, "total": len(sessions)}
except Exception as e:
return {"sessions": [], "error": str(e)}
@app.get("/api/fs/list")
async def list_directory(path: str = ""):
"""List subdirectories for the path picker."""
@@ -408,6 +510,10 @@ async def create_tmux_session(data: dict):
return {"status": "exists", "session": session_name, "tmux": existing}
try:
# Ensure project directory exists
if project_dir:
os.makedirs(project_dir, exist_ok=True)
# Create detached session
subprocess.run(
["tmux", "new-session", "-d", "-s", session_name, "-n", "dev", "-c", project_dir or str(BASE_DIR)],
@@ -569,11 +675,99 @@ async def startup():
asyncio.create_task(_periodic_broadcast())
# ── Auto-detection of Claude Code progress ──────────
# Track which completions we've already processed to avoid double-counting
_seen_completions: set = set()
async def _auto_detect_progress():
"""Scan tmux output for Claude Code task completion patterns and auto-update steps."""
tasks = load_tasks()
agents = load_agents()
changed = False
for tid, task in tasks.items():
if task.get("status") != "running":
continue
project_name = task.get("project", "")
# Find the tmux session for this project's agent
proj_agents = [a for a in agents.values() if a.get("project") == project_name]
if not proj_agents:
continue
session = proj_agents[0].get("tmux_session", "")
if not session:
continue
# Capture terminal output
cap = _capture_tmux(session, "0", 100)
content = cap.get("content", "")
# Pattern: ✔ <step_name> (Claude Code completion checkmark)
import re as _re
completed_items = _re.findall(r'\s+(.+?)(?:\n|$)', content)
steps = task.get("steps", [])
for step_idx, step in enumerate(steps):
if step.get("status") not in ("running", "pending"):
continue
step_name = step.get("name", "")
for item in completed_items:
# Fuzzy match: check if the completed item contains step name keywords
item_lower = item.lower().strip()
step_lower = step_name.lower().strip()
# Try direct match or keyword match
match = (
step_lower in item_lower or
item_lower in step_lower or
any(kw in item_lower for kw in step_lower.split() if len(kw) > 2)
)
if match:
dedup_key = f"{tid}:{step_idx}:{item}"
if dedup_key in _seen_completions:
continue
_seen_completions.add(dedup_key)
# Auto-complete this step
now = datetime.now().isoformat()
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
next_idx = step_idx + 1
if next_idx < len(steps):
steps[next_idx]["status"] = "running"
steps[next_idx]["started_at"] = now
task["current_step"] = next_idx
else:
task["status"] = "completed"
task["completed_at"] = now
task["current_step"] = len(steps)
add_log("info", "auto-detect", f"{project_name}: step {step_idx+1}/{len(steps)} '{step_name}' → completed (auto)")
changed = True
break
if changed:
save_tasks(tasks)
async def _periodic_broadcast():
while True:
await asyncio.sleep(10)
if ws_clients:
try:
await _auto_detect_progress()
if ws_clients:
await broadcast(await _build_state())
except Exception:
pass