From 930138b1f6738506bc289ca49ca4b04e5278bfd0 Mon Sep 17 00:00:00 2001 From: wusumac <736139669@qq.com> Date: Fri, 29 May 2026 16:00:59 +0800 Subject: [PATCH] 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 --- CLAUDE.md | 138 +++++++++--------- README.md | 90 ++++++------ frontend/dashboard.html | 310 +++++++++++++++++++++++++++++++++++++--- server.py | 228 ++++++++++++++++++++++++++--- 4 files changed, 621 insertions(+), 145 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0710c16..ead652b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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里) - │ │ │ - ├─ 新建项目+派发任务 ──→ │ │ - │ ├─ 创建 tmux session ────→ │ - │ ├─ 发送任务指令 ──────────→ │ - │ │ ├─ 拆步骤 - │ │ ├─ 写代码 - │ │ ├─ 弹确认框 - │ │ ← 检测到确认弹窗 ──────── │ - │ ← 显示确认按钮 ─────── │ │ - ├─ 点击确认 ────────────→ │ ──→ 发送确认 ──────────→ │ - │ │ ├─ 继续执行 - │ │ ← 步骤完成通知 ────────── │ - │ ← 步骤进度更新 ─────── │ │ - │ │ ├─ 全部完成 +Human (You) OpsPilot (Control Center) Claude Code (in tmux) + │ │ │ + ├─ 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) diff --git a/README.md b/README.md index c34dfb3..79a0955 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,109 @@ # 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 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 │ +│ REST API + WebSocket │ └─────────────────────┬───────────────────────────┘ │ tmux send-keys / capture-pane ┌───────────┼───────────┐ ▼ ▼ ▼ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ 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 diff --git a/frontend/dashboard.html b/frontend/dashboard.html index af9208a..c500330 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -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 @@
-
+
📁 Projects @@ -164,8 +178,10 @@
+
+ -
+
📋 Task Progress
@@ -181,8 +197,10 @@
+
+ -
+
🖥 Terminal & Controls @@ -193,9 +211,11 @@
- - +
+ + +
${stepHtml}
- ${activeTask.status === 'completed' ? '
🎉 任务全部完成!请派发新任务。
' : ''} + ${activeTask.status === 'completed' ? '
🎉 任务全部完成!
' : ''} + // Session status + const sessionRunning = projAgents.some(a => a.tmux?.running); + const claudeActive = termContent.includes('❯') && !termContent.includes('Welcome back'); + + let sessionHtml = '
'; + // Status pills + sessionHtml += ``; + sessionHtml += ` tmux: ${sessionRunning ? '在线' : '离线'}`; + sessionHtml += ``; + sessionHtml += ``; + sessionHtml += ` Claude: ${claudeActive ? '运行中' : (sessionRunning ? '待检测' : '未启动')}`; + sessionHtml += ``; + sessionHtml += ''; + // Action buttons + sessionHtml += ``; + sessionHtml += ``; + sessionHtml += ``; + sessionHtml += ``; + sessionHtml += '
'; + + 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 = '
此目录下没有子文件夹
'; + let html = ''; + // Parent directory ".." entry (only if not at root) + if (d.path !== '/' && d.parent) { + html += `
+ 📂 .. +
`; + } + if (d.dirs.length === 0 && !html) { + html += '
此目录下没有子文件夹
'; } else { - picker.innerHTML = d.dirs.map(dir => ` + html += d.dirs.map(dir => `
📁 ${escapeHtml(dir.name)}
`).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 = ` +
+
选择要恢复的 Claude Code 会话:
+
+ ${sessions.slice(0, 10).map(s => ` +
+ ${s.id.substring(0,12)}... + ${s.status} · ${s.updated_at ? new Date(s.updated_at).toLocaleString('zh-CN') : ''} +
+ `).join('')} +
+ + +
+ `; + 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); diff --git a/server.py b/server.py index fc184d5..a54d401 100644 --- a/server.py +++ b/server.py @@ -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,14 +675,102 @@ 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: ✔ (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: + try: + await _auto_detect_progress() + if ws_clients: await broadcast(await _build_state()) - except Exception: - pass + except Exception: + pass if __name__ == "__main__":