mirror of
https://github.com/wu736139669/OpsPilot.git
synced 2026-08-05 06:23:35 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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+、tmux、Claude 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
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user