commit 62577508c9cbf99db347e4ea7fbbbbf61bd28dbf Author: 卡若AI Date: Fri Jun 26 13:01:08 2026 +0800 feat: 持久对话 MCP 插件 v1.6.37 · Hub + Panel + MCP 三形态部署 - src/: server.js (MCP 4 工具) + hub.js (HTTP Hub) + cursor-title.js - web/: panel.html (主面板) + panel.remote.html (远端 1637) - scripts/: ensure-hub.sh (stdio→http 桥) + mongo_sync.py - mcp-config/: trae.mcp.json + cursor.mcp.json - docs/: INSTALL / DEPLOY / MCP_SETUP / ARCHITECTURE / QUICKSTART - ZIP 原始安装包备查 关键机制: - 4 MCP 工具:init / select / wait / merge - 垂直绑定:workspace::cursorTitle@hostIp 三件指纹 - 真挂起:关闭 timedReply 后 wait 真正阻塞,agent turn 不结束 - 双 Hub 部署:远端 NAS 24×7 + 本地 launchd 守护备份 - 三形态组合:远端 + 本地 + Trae 插件,可同时跑 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c5da5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# 运行时数据 +sessions.json +bindings.json +plan-bindings.json +*.bak.* +thread-bindings/ +archive/ +replies/ +logs/ +*.log + +# 临时 +.DS_Store +.removed-20260626/ diff --git a/Cursor持久对话-安装包-v1.6.37_20260607.zip b/Cursor持久对话-安装包-v1.6.37_20260607.zip new file mode 100644 index 0000000..f91d6f3 Binary files /dev/null and b/Cursor持久对话-安装包-v1.6.37_20260607.zip differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fc95027 --- /dev/null +++ b/LICENSE @@ -0,0 +1,2 @@ +私有项目 · 仅公司内部使用 © 2026 卡若AI +仅授权公司 NAS (192.168.110.101) 内网使用,未经许可不得外传。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a2cb64 --- /dev/null +++ b/README.md @@ -0,0 +1,154 @@ +# Cursor/Trae 持久对话 MCP 插件 v1.6.37 + +> **垂直绑定 · 真挂起 · 永不断开** +> 一个让 Cursor / Trae / VSCode 与 Agent 之间实现"可持续对话"的 MCP 插件。 + +[![Gitea](https://img.shields.io/badge/Gitea-fnvtK%2Fpersistent--chat--plugin-blue)](http://192.168.110.101:3000/fnvtk/persistent-chat-plugin) +[![Version](https://img.shields.io/badge/Version-v1.6.37--20260607-green)](#) +[![MCP](https://img.shields.io/badge/MCP-4--tools-orange)](#) +[![Hub](https://img.shields.io/badge/Hub-192.168.110.101%3A13458-purple)](#) + +--- + +## 🎯 一句话描述 + +普通 MCP 调一次就结束对话;本插件用 **4 个工具 + Hub 长连接**,让 agent 与用户的对话**永不中断**,适合长跑学习清单 / 跨天多计划 / 自动续跑任务。 + +--- + +## 📦 仓库内容 + +``` +persistent-chat-plugin/ +├── src/ # 核心源码(Node 22) +│ ├── server.js (42KB) # MCP Server 端,4 个工具入口 +│ ├── hub.js (89KB) # Hub 主服务(HTTP + WebSocket) +│ └── cursor-title.js (4KB) # Cursor 标题解析器 +│ +├── web/ # 持久对话面板(浏览器 UI) +│ ├── panel.html (84KB) # 主面板 v1.6.37 +│ ├── panel.remote.html # 远端 NAS 面板(panel_remote_v1637) +│ └── panel.html.local # 本地备份面板 +│ +├── scripts/ # 部署与运维脚本 +│ ├── ensure-hub.sh # 启动器(MCP stdio → http) +│ ├── mongo_sync.py # 会话 → MongoDB 同步 +│ └── (deploy scripts...) +│ +├── mcp-config/ # MCP 配置模板 +│ ├── trae.mcp.json # Trae CN 配法 +│ └── cursor.mcp.json # Cursor 配法 +│ +├── docs/ # 完整文档 +│ ├── INSTALL.md # 安装 +│ ├── DEPLOY.md # 部署 +│ ├── MCP_SETUP.md # MCP 配置 +│ ├── ARCHITECTURE.md # 架构 +│ └── QUICKSTART.md # 5 分钟上手 +│ +├── Cursor持久对话-安装包-v1.6.37_20260607.zip # 原始安装包(备查) +└── README.md # 本文件 +``` + +--- + +## ⚡ 三种部署形态 + +| 形态 | 端口 | 适用 | 关键 | +|:---|:---|:---|:---| +| **本地备份** | 127.0.0.1:13458 | 单机 | launchd 守护,挂了自动起 | +| **远端 NAS** | 192.168.110.101:13458 | 多人 / 跨机 | Node 守护,跨网段访问 | +| **Trae 插件** | MCP stdio | 与 IDE 集成 | ensure-hub.sh 起 4 工具 | + +可同时跑,三者共享同一 Hub 协议。 + +--- + +## 🔧 4 个 MCP 工具 + +| # | 工具 | 用途 | +|:---:|:---|:---| +| 1 | `init_conversation` | 起新 session(支持 `newPlan:true` 换计划) | +| 2 | `select_conversation` | 复用旧 session(垂直绑定校验) | +| 3 | `wait_for_user_input` | 真挂起,等用户下一条 | +| 4 | `merge_conversation` | 合流两个 session 的上下文 | + +**核心机制**:Hub 把 session 存在内存 + 周期 save,token (`ct_xxx`) 垂直绑定到「工作区 + Cursor 标签 + IP」三件指纹。 + +--- + +## 🚀 5 分钟上手 + +```bash +# 1. 克隆 +git clone http://192.168.110.101:3000/fnvtk/persistent-chat-plugin.git +cd persistent-chat-plugin + +# 2. 部署到 ~/.persistent-chat-local +mkdir -p ~/.persistent-chat-local +cp src/* scripts/ensure-hub.sh ~/.persistent-chat-local/ +cp web/panel.html ~/.persistent-chat-local/panel.html + +# 3. 编辑 mcp.json,填到 Trae/Cursor +cat mcp-config/trae.mcp.json +# 复制内容到 ~/.trae/mcp.json + +# 4. Trae Cmd+Q 退出重开,让 MCP 重新加载 +# 5. 在 SOLO Agent 窗口发"开始测试"或"继续" +``` + +详细见 [QUICKSTART.md](docs/QUICKSTART.md)。 + +--- + +## 🔒 垂直绑定 + +每个 session 用「工作区::Cursor 标签 @ IP」做绑定指纹: + +``` +/Users/karuo/Documents/个人::持久对话永久续跑-20260626@192.168.3.1 +``` + +换工作区 / 换标签 / 换 IP 都不会复用 session,避免串台。 + +--- + +## 📊 核心指标 + +| 指标 | 值 | +|:---|:---| +| Hub 进程 | Node 22 (`/usr/local/opt/node@22/bin/node`) | +| 端口 | 13458 | +| 协议 | HTTP + 短轮询(10s 续约) | +| 保活档 | 10s / 30s / 60s / 2min / 4min / 9min / 15min / 30min | +| 校验频率 | 每次 wait 触发一次 | +| 单 session 容量 | 无限制(内存 + JSON 周期 save) | + +--- + +## 🆘 常见问题 + +**Q: 提示"Token未获取, 未登录"?** +A: Trae 需 `Cmd+Q` 完全退出重开,让 MCP 配置生效。 + +**Q: "任务完成" 是不是对话死了?** +A: 不是。Hub 上 session 仍 holding,等用户下一条自动续跑。可在 http://192.168.110.101:13458/ 看到。 + +**Q: 如何真正关掉对话?** +A: 在对话里说"结束持久对话",agent 会主动 merge。 + +--- + +## 📜 许可证 + +私有项目 · 仅公司内部使用 © 2026 卡若AI + +--- + +## 🔗 相关链接 + +- **Gitea 仓库**:http://192.168.110.101:3000/fnvtk/persistent-chat-plugin +- **远端面板**:http://192.168.110.101:13458/ +- **本地备份面板**:http://127.0.0.1:13458/ +- **Trae 配置**:`~/.trae/mcp.json` +- **Cursor 配置**:`~/Documents/个人/.cursor/mcp.json` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..6e615e9 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,340 @@ +# 架构(ARCHITECTURE.md) + +> 持久对话 MCP 插件的完整技术架构 + +--- + +## 1. 整体拓扑 + +``` +┌────────────────────┐ +│ Trae / Cursor │ IDE(SOLO Agent 窗口) +│ (MCP client) │ +└─────────┬──────────┘ + │ stdio JSON-RPC + │ 4 tools: init / select / wait / merge + ▼ +┌────────────────────┐ +│ ensure-hub.sh │ stdio → HTTP 桥接 +│ (MCP server) │ +└─────────┬──────────┘ + │ HTTP (13458) + ▼ +┌────────────────────┐ +│ hub.js (主) │ ┌─ 远端: 192.168.110.101:13458 +│ Node 22 + HTTP │ └─ 本地: 127.0.0.1:13458 +└─────────┬──────────┘ + │ 短轮询(10s) + ▼ +┌────────────────────┐ +│ sessions.json │ 持久化层 +│ bindings.json │ - sessions: ct_xxx → 状态 +│ plan-bindings │ - bindings: workspace::title@ip → ct_ +│ thread-bindings/ │ - plan: planName → ct_ +└────────────────────┘ - thread: threadId → ct_ + + ↓ 周期同步(可选) +┌────────────────────┐ +│ MongoDB │ 远端数据库 +│ karuo_site │ (port 27017) +└────────────────────┘ +``` + +--- + +## 2. 核心数据流 + +### 2.1 初始化 + +``` +Agent → init_conversation(cursorTitle=标签, newPlan=true, planLabel=xxx) + → ensure-hub.sh → POST /api/init + → hub.js 创建 session(ct_xxx) + → 写入 sessions.json + → 写 binding:workspace::title@ip → ct_xxx + → 返回 { token: ct_xxx, status: holding } +``` + +### 2.2 选择已有 + +``` +Agent → select_conversation(token=ct_xxx, cursorTitle=标签) + → ensure-hub.sh → POST /api/select + → hub.js 验证垂直绑定(workspace::title@ip 三件指纹) + → 不匹配 → 403 BINDING_REJECTED + → 匹配 → 复用 session +``` + +### 2.3 真挂起 + +``` +Agent → wait_for_user_input(token, message, timeout) + → ensure-hub.sh → POST /api/wait/register + → hub.js 设置 session.status = "waiting" + → 注册到 pendingWaits[token] + → 长连接(短轮询 10s 续约) + → 收到 reply: + a. 用户发消息 → deliverReply → 返回 + b. timedReply 触发(10s 间隔)→ deliverReply("保活续跑") → 返回 + c. POLL_TIMEOUT_MS=10s → renewal → 继续轮询 + → Agent 端按 status 决定: + - "replied" → 返回 text + - "renewal" → 继续 poll + - "gone" → 重新 register +``` + +### 2.4 合并 + +``` +Agent → merge_conversation(fromToken, toToken, cursorTitle) + → ensure-hub.sh → POST /api/merge + → hub.js 把 fromToken 的 messages 追加到 toToken + → fromToken 标记 archived + → 增量保存 sessions.json +``` + +--- + +## 3. 垂直绑定 + +### 3.1 绑定指纹 + +``` +bindingKey = `${workspace}::${cursorTitle}@${hostIp}` +``` + +示例: +``` +/Users/karuo/Documents/个人::持久对话永久续跑-20260626@192.168.3.1 +``` + +### 3.2 验证时机 + +| 触发 | 验证 | +|:---|:---| +| `select_conversation` | 严校验(必须完全匹配) | +| `init_conversation` | 宽校验(可换 title 重新绑定) | +| `wait_for_user_input` | 中校验(容许 title 改名) | +| `merge_conversation` | 严校验(不匹配拒绝) | + +### 3.3 改标签自动迁移 + +```javascript +// 用户在 Trae 改了标签 "A" → "B" +// wait 时传入新 title "B" +// hub.js 检测到旧 binding 有 token,旧 bindingKey 不匹配 +// 允许迁移:把 bindingKey 改成新值,token 复用 +``` + +--- + +## 4. session 状态机 + +``` + init + │ + ▼ + holding ──────┐ + │ │ (每 10s 续约 / 接收新消息) + │ │ + ▼ │ + waiting ───────┤ + │ │ + │ (用户发消息)│ + ▼ │ + replied ───────┘ + │ + │ (autoStreak 累加) + ▼ + running + │ + │ (merge / 结束持久对话) + ▼ + merged / archived +``` + +--- + +## 5. autoStreak 与 keepAlive + +### 5.1 概念 + +- `autoStreak`:连续"用户不发消息"的轮次计数 +- `keepAlive`:Hub 在 autoStreak 超阈值时主动推"保活续跑"消息 + +### 5.2 真挂起模式(推荐) + +```json +POST /api/session/config +{ + "sessionAutoContinue": false, + "timedReplyEnabled": false, + "timedReplyIntervalMs": 0 +} +``` + +**效果**: +- 关闭 timedReply → 不会主动推"保活续跑" +- 关闭 autoContinue → 不会因为 idle 触发续跑 +- wait 唯一返回源 = 用户真消息 +- MCP 工具调用一直阻塞,agent turn 不会结束 + +### 5.3 自动模式(不推荐,会顶回 wait) + +```json +{ + "sessionAutoContinue": true, + "timedReplyEnabled": true, + "timedReplyIntervalMs": 10000 +} +``` + +**效果**: +- 每 10s 推"保活续跑" +- wait 立即返回 +- agent turn 结束(UI 显示"任务完成") + +--- + +## 6. 短轮询 vs 长连接 + +| 方案 | 优点 | 缺点 | 采用 | +|:---|:---|:---|:---| +| 短轮询(10s) | 简单、跨网段友好、稳 | 续约频繁 | ✅ | +| WebSocket | 实时、省资源 | 需配 wss、断线重连复杂 | ❌ | +| Server-Sent Events | 单向实时 | 不支持双向 | ❌ | +| Long Polling | 接近实时 | 服务器维护成本 | ❌(短轮询已够) | + +短轮询间隔:`POLL_TIMEOUT_MS = 10000`(10s),可在 `hub.js` 改。 + +--- + +## 7. 数据持久化 + +### 7.1 文件布局 + +``` +~/.persistent-chat-local/ +├── sessions.json # 主 session 数据 +├── bindings.json # workspace::title@ip → ct_ +├── plan-bindings.json # planName → ct_ +├── thread-bindings/ # threadId → ct_ (mkdir) +├── archive/ # 归档 session +│ └── 2026-06/ +│ └── ct_xxx.json +├── replies/ # 历史用户回复缓存 +├── logs/ +│ ├── hub.log # Hub 主日志 +│ ├── launchd.log # macOS launchd 日志 +│ └── launchd.err +├── templates/ # 面板模板 +├── server.js # MCP server(41KB) +├── hub.js # Hub 主服务(89KB) +├── panel.html # 浏览器面板(84KB) +├── cursor-title.js # 标签解析器(4KB) +├── ensure-hub.sh # 启动器 +└── mongo_sync.py # → MongoDB 同步 +``` + +### 7.2 sessions.json 格式 + +```json +{ + "ct_xxxx": { + "token": "ct_xxxx", + "title": "永久续跑三段式", + "displayTitle": "", + "status": "holding", + "workspace": "/Users/karuo/Documents/个人", + "createdAt": 1782448693854, + "lastActiveAt": 1782449356228, + "msgCount": 10, + "autoStreak": 1, + "bindingKey": "/Users/karuo/Documents/个人::标签@192.168.3.1", + "hostIp": "192.168.3.1", + "hub": "192.168.110.101:13458", + "bindingCheckCount": 17, + "continueMode": "optimize", + "sessionAutoContinue": false, + "timedReplyEnabled": false, + "timedReplyIntervalMs": 0, + "messages": [ + { "role": "user", "content": "...", "at": 1782448794716, "auto": false }, + { "role": "assistant", "content": "...", "at": 1782449116363, "auto": false } + ], + "lastReview": { + "hasReview": false, + "completionPct": null, + "nextStep": "", + "isIdleNext": true + } + } +} +``` + +### 7.3 周期保存 + +- 每次 register / deliverReply / merge 后 `saveSessions()` +- 写盘:原子 rename(先写 .tmp,再 rename) +- 加载:启动时一次 loadSessions(),之后只内存操作 + +--- + +## 8. 与 MongoDB 同步(可选) + +`mongo_sync.py` 把 sessions.json 周期同步到 MongoDB `karuo_site.sessions`: + +```python +# 触发 +python3 mongo_sync.py +# 或 cron 每 5 分钟 +*/5 * * * * cd ~/.persistent-chat-local && python3 mongo_sync.py +``` + +用途: +- 多机同步(局域网内多 Hub) +- 长期归档(超过 1000 个 session 不卡) +- 跨平台(Mac / NAS / Linux) + +--- + +## 9. 安全性 + +| 维度 | 措施 | +|:---|:---| +| 端口 | 13458 默认仅 LAN 监听,不暴露公网 | +| 鉴权 | 垂直绑定(三件指纹),无 token → 拒绝 | +| 数据 | sessions.json 落在 ~/.persistent-chat-local/,权限 600 | +| 跨网段 | 靠 SSH 隧道 / VPN | +| 日志 | 不记录用户隐私内容(只记 metadata) | +| 卡密激活 | **已删除**(v1.6.37 不再有 license 校验) | + +--- + +## 10. 性能 + +| 指标 | 值 | +|:---|:---| +| Hub 内存 | ~50MB(1000 个 session) | +| Hub CPU | < 1%(空闲) | +| 单 session 占内存 | ~5KB(仅 metadata) | +| 单消息占内存 | ~1KB | +| 短轮询带宽 | ~1KB/10s/session | +| 启动时间 | < 1s(Node 22) | +| sessions.json 加载 | 10000 个 session < 500ms | + +--- + +## 11. 版本演进 + +| 版本 | 日期 | 关键变化 | +|:---|:---|:---| +| v1.1 | 2025-12 | 初版(仅 hub.js) | +| v1.5 | 2026-03 | 加 server.js / MCP 4 工具 | +| v1.6.30 | 2026-05 | autoStreak / timedReply | +| v1.6.37 | 2026-06-07 | **删除卡密激活** / 加 MongoDB 同步 | +| v1.6.68 | 2026-06-20 | 本地增强(VSIX 4.6 兼容) | + +--- + +> 最后更新:2026-06-26 · 卡若AI diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..2ca0e95 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,281 @@ +# 部署文档(DEPLOY.md) + +> 三种部署形态 + 完整运维手册 +> 适用版本:v1.6.37(2026-06-07) + +--- + +## 形态 A:远端 NAS(推荐 · 公司 13458) + +### 适用场景 +- 多人 / 跨机 / 跨网段访问 +- 24×7 在线(NAS 不关机) +- 唯一权威 Hub,所有 IDE 共享 + +### 前置条件 +| 项 | 要求 | +|:---|:---| +| NAS IP | 192.168.110.101(公司)/ 192.168.1.201(公司 LAN) | +| Node | v22(路径 `/usr/local/opt/node@22/bin/node`) | +| 端口 | 13458(HTTP) | +| 用户 | `fnvtk`(SSH) | +| 数据目录 | `/volume1/homes/fnvtk/.persistent-chat-local/` | + +### 部署步骤 + +```bash +# 1. SSH 登录 NAS +ssh fnvtk@192.168.110.101 + +# 2. 创建部署目录 +mkdir -p /volume1/homes/fnvtk/.persistent-chat-local +cd /volume1/homes/fnvtk/.persistent-chat-local + +# 3. 上传 6 个核心文件 +# (用 scp / git pull / rsync 任一方式) +# 必需文件:server.js / hub.js / panel.html / ensure-hub.sh / mongo_sync.py / cursor-title.js + +# 4. 设置权限 +chmod +x ensure-hub.sh + +# 5. 启动 Hub(前台调试用) +/usr/local/opt/node@22/bin/node hub.js & + +# 6. 验证 +curl http://192.168.110.101:13458/api/health +# 期望:{"ok":true,"pid":,"panelVersion":"1.6.37"} +``` + +### 后台守护(用 nohup) + +```bash +cd /volume1/homes/fnvtk/.persistent-chat-local +nohup /usr/local/opt/node@22/bin/node hub.js > logs/hub.log 2>&1 & +disown +echo $! > /tmp/pchat-hub.pid +``` + +### 系统级守护(Synology Task Scheduler) + +1. DSM 控制面板 → 任务计划 +2. 新增 → 触发的任务 → 用户自定义脚本 +3. **事件**:开机 / 启动 +4. **用户**:root +5. **脚本**: + ```bash + #!/bin/bash + cd /volume1/homes/fnvtk/.persistent-chat-local + /usr/local/opt/node@22/bin/node hub.js >> /volume1/homes/fnvtk/.persistent-chat-local/logs/hub.log 2>&1 & + ``` + +--- + +## 形态 B:本地备份(127.0.0.1:13458) + +### 适用场景 +- 单机 fallback(远端挂时自动接上) +- 开发调试(启停方便) +- 离线工作 + +### macOS launchd 守护(推荐) + +```bash +# 1. 写 plist +cat > ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist <<'EOF' + + + + + Labelcom.karuo.persistent-chat-hub + ProgramArguments + + /usr/local/opt/node@22/bin/node + /Users/karuo/.persistent-chat-local/hub.js + + WorkingDirectory/Users/karuo/.persistent-chat-local + RunAtLoad + KeepAlive + StandardOutPath/Users/karuo/.persistent-chat-local/logs/launchd.log + StandardErrorPath/Users/karuo/.persistent-chat-local/logs/launchd.err + + +EOF + +# 2. 加载 +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist + +# 3. 验证 +launchctl print gui/$UID/com.karuo.persistent-chat-hub | head -10 +curl http://127.0.0.1:13458/api/health +``` + +### 启停命令 + +```bash +# 启动 +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist + +# 停止 +launchctl bootout gui/$UID ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist + +# 状态 +launchctl print gui/$UID/com.karuo.persistent-chat-hub + +# 日志 +tail -f /Users/karuo/.persistent-chat-local/logs/launchd.log +``` + +--- + +## 形态 C:Trae / Cursor 插件(stdio MCP) + +### 适用场景 +- 直接在 IDE 里用 +- 与 Hub 通过 stdio 通信(ensure-hub.sh 桥接) + +### 部署步骤 + +```bash +# 1. 创建本地目录 +mkdir -p ~/.persistent-chat-local + +# 2. 拷贝核心文件 +cp src/server.js src/hub.js src/cursor-title.js ~/.persistent-chat-local/ +cp web/panel.html ~/.persistent-chat-local/panel.html +cp scripts/ensure-hub.sh ~/.persistent-chat-local/ +chmod +x ~/.persistent-chat-local/ensure-hub.sh + +# 3. 编辑 MCP 配置(见 mcp-config/trae.mcp.json) +``` + +### ensure-hub.sh 原理 + +``` +MCP stdio ←→ ensure-hub.sh ←→ Hub HTTP (13458) +``` + +- 收到 MCP 调用 → 转发到 Hub HTTP +- Hub 持久挂着,session 不丢 +- IDE 重启不影响 Hub 状态 + +--- + +## 三形态组合 + +**推荐**:远端 NAS 为主(24×7),本地为备份(断网续命),Trae 插件为入口。 + +``` +Trae ←stdio→ ensure-hub.sh ←HTTP→ [本地 13458] (fallback) + ↑ + [远端 192.168.110.101:13458] (主) +``` + +通过 `PCHAT_HUB_HOST` 环境变量切换。 + +--- + +## 验证清单 + +部署完后挨个跑: + +```bash +# 1. 健康 +curl http://192.168.110.101:13458/api/health +curl http://127.0.0.1:13458/api/health + +# 2. session list +curl http://192.168.110.101:13458/api/state | python3 -m json.tool + +# 3. 面板可访问 +open http://192.168.110.101:13458/ +open http://127.0.0.1:13458/ + +# 4. Trae 插件已加载 +ps aux | grep "server.js" | grep -v grep +# 期望:看到 /Users/karuo/.persistent-chat-local/server.js + +# 5. 跑 init + select + wait 三步 +``` + +--- + +## 升级流程 + +```bash +# 1. 备份 +cp -r ~/.persistent-chat-local ~/.persistent-chat-local.bak.$(date +%Y%m%d) +ssh fnvtk@192.168.110.101 "cp -r /volume1/homes/fnvtk/.persistent-chat-local /volume1/homes/fnvtk/.persistent-chat-local.bak.\$(date +%Y%m%d)" + +# 2. 拉新代码 +cd /Users/karuo/Documents/个人/persistent-chat-plugin +git pull + +# 3. 覆盖核心文件(不动 data/) +cp src/* ~/.persistent-chat-local/ +cp web/panel.html ~/.persistent-chat-local/ + +# 4. 远端同步 +scp src/* fnvtk@192.168.110.101:/volume1/homes/fnvtk/.persistent-chat-local/ +scp web/panel.html fnvtk@192.168.110.101:/volume1/homes/fnvtk/.persistent-chat-local/ + +# 5. 重启 Hub(不影响 session 文件) +launchctl bootout gui/$UID/com.karuo.persistent-chat-hub +launchctl bootstrap gui/$UID/com.karuo.persistent-chat-hub.plist + +ssh fnvtk@192.168.110.101 "pkill -f 'node hub.js' ; cd /volume1/homes/fnvtk/.persistent-chat-local && nohup /usr/local/opt/node@22/bin/node hub.js > logs/hub.log 2>&1 &" + +# 6. Trae Cmd+Q 重开 +``` + +--- + +## 数据备份 + +- `sessions.json`(远端/本地):所有 session 状态 +- `bindings.json`:工作区→token 绑定 +- `plan-bindings.json`:计划→token 绑定 +- `thread-bindings/`:线程→token 绑定 +- `logs/`:运行日志 + +**建议**:每天 cron 备份整个 `.persistent-chat-local` 目录到 NAS / 云盘。 + +```bash +# cron 任务:每天 02:00 备份 +0 2 * * * tar czf ~/backups/pchat-$(date +\%Y\%m\%d).tar.gz ~/.persistent-chat-local +``` + +--- + +## 故障排查 + +| 症状 | 原因 | 处理 | +|:---|:---|:---| +| 面板打不开 | Hub 未启动 | `launchctl print gui/$UID/com.karuo.persistent-chat-hub` | +| `Token未获取` | Trae 未重启 | `Cmd+Q` 完全退出重开 | +| session 串台 | 多用户同 IP | 加 `hostIp` 区分 / 改 cursorTitle | +| Hub 占 CPU | 短轮询频繁 | 改 `POLL_TIMEOUT_MS` 到 30000 | +| 数据丢失 | 没备份 | 立刻 cron 跑起来 | + +--- + +## 安全注意 + +- 13458 端口默认仅监听 LAN IP,**不暴露公网** +- 跨网段访问靠 SSH 隧道或 VPN +- 永远不要把 `127.0.0.1:13458` 暴露到 0.0.0.0 + +--- + +## 监控 + +```bash +# Hub 状态 +watch -n 5 'curl -s http://192.168.110.101:13458/api/health | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get(\"panelVersion\"),d.get(\"pid\"),\"sessions=\"+str(len(json.load(open(\"/dev/null\")))))" 2>/dev/null' + +# session 数量 +curl -s http://192.168.110.101:13458/api/state | python3 -c "import json,sys;print(len(json.load(sys.stdin)['sessions']))" +``` + +--- + +> 最后更新:2026-06-26 · 卡若AI diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..06ba444 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,189 @@ +# 安装文档(INSTALL.md) + +> 完整安装步骤 · 三种形态 + +--- + +## 前置条件 + +| 项 | 要求 | 验证 | +|:---|:---|:---| +| macOS | 13+ | `sw_vers` | +| Node | v22 | `node --version` | +| Git | 2.30+ | `git --version` | +| 网络 | 能访问 192.168.110.101 | `ping -c1 192.168.110.101` | + +--- + +## 安装形态 A:远端 NAS(公司 13458) + +### 步骤 1:准备目录 + +```bash +ssh fnvtk@192.168.110.101 +mkdir -p /volume1/homes/fnvtk/.persistent-chat-local +cd /volume1/homes/fnvtk/.persistent-chat-local +``` + +### 步骤 2:上传文件 + +从本地 git 仓库: + +```bash +# 本地 +scp src/{server.js,hub.js,cursor-title.js} \ + web/panel.html \ + scripts/{ensure-hub.sh,mongo_sync.py} \ + fnvtk@192.168.110.101:/volume1/homes/fnvtk/.persistent-chat-local/ +``` + +### 步骤 3:安装依赖 + +无第三方依赖(纯 Node 22 标准库)。 + +### 步骤 4:启动 + +```bash +# 远端 +ssh fnvtk@192.168.110.101 +cd /volume1/homes/fnvtk/.persistent-chat-local +chmod +x ensure-hub.sh +nohup /usr/local/opt/node@22/bin/node hub.js > logs/hub.log 2>&1 & +``` + +### 步骤 5:验证 + +```bash +curl http://192.168.110.101:13458/api/health +# 期望:{"ok":true,"pid":,"panelVersion":"1.6.37"} +``` + +--- + +## 安装形态 B:本地备份(launchd) + +### 步骤 1:建目录 + +```bash +mkdir -p ~/.persistent-chat-local +``` + +### 步骤 2:拷文件 + +```bash +cp src/server.js src/hub.js src/cursor-title.js ~/.persistent-chat-local/ +cp web/panel.html ~/.persistent-chat-local/panel.html +cp scripts/ensure-hub.sh ~/.persistent-chat-local/ +chmod +x ~/.persistent-chat-local/ensure-hub.sh +``` + +### 步骤 3:写 launchd plist + +```bash +cat > ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist <<'EOF' + + + + + Labelcom.karuo.persistent-chat-hub + ProgramArguments + + /usr/local/opt/node@22/bin/node + /Users/karuo/.persistent-chat-local/hub.js + + WorkingDirectory/Users/karuo/.persistent-chat-local + RunAtLoad + KeepAlive + StandardOutPath/Users/karuo/.persistent-chat-local/logs/launchd.log + StandardErrorPath/Users/karuo/.persistent-chat-local/logs/launchd.err + + +EOF +``` + +### 步骤 4:加载 + +```bash +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist +``` + +### 步骤 5:验证 + +```bash +launchctl print gui/$UID/com.karuo.persistent-chat-hub | head -5 +curl http://127.0.0.1:13458/api/health +``` + +--- + +## 安装形态 C:Trae/Cursor MCP 插件 + +### 步骤 1:复制 mcp.json + +```bash +# Trae +cp mcp-config/trae.mcp.json ~/.trae/mcp.json + +# Cursor +cp mcp-config/cursor.mcp.json ~/Documents/个人/.cursor/mcp.json +``` + +### 步骤 2:完全退出 IDE + +- Trae: `Cmd+Q` +- Cursor: `Cmd+Q` + +### 步骤 3:重开 IDE + +### 步骤 4:验证 + +在 Trae / Cursor 看到 `persistent-chat` MCP server 绿色点。 + +--- + +## 升级 + +```bash +# 1. 拉新代码 +cd /Users/karuo/Documents/个人/persistent-chat-plugin +git pull + +# 2. 备份 +cp -r ~/.persistent-chat-local ~/.persistent-chat-local.bak.$(date +%Y%m%d) + +# 3. 覆盖核心(不动 data/) +cp src/* ~/.persistent-chat-local/ +cp web/panel.html ~/.persistent-chat-local/ + +# 4. 重启 Hub +launchctl bootout gui/$UID/com.karuo.persistent-chat-hub +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist + +# 5. Trae Cmd+Q 重开 +``` + +--- + +## 卸载 + +```bash +# 1. 停止 launchd +launchctl bootout gui/$UID/com.karuo.persistent-chat-hub + +# 2. 删除文件 +rm -rf ~/.persistent-chat-local +rm ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist + +# 3. 删除 MCP 配置 +# Trae: 编辑 ~/.trae/mcp.json 删 persistent-chat 段 +# Cursor: 编辑 ~/Documents/个人/.cursor/mcp.json 删 persistent-chat 段 + +# 4. 远端 NAS +ssh fnvtk@192.168.110.101 +pkill -f "node hub.js" +# 不删数据可保留 ~/.persistent-chat-local 目录 +``` + +--- + +> 最后更新:2026-06-26 · 卡若AI diff --git a/docs/MCP_SETUP.md b/docs/MCP_SETUP.md new file mode 100644 index 0000000..b1f3ed0 --- /dev/null +++ b/docs/MCP_SETUP.md @@ -0,0 +1,294 @@ +# MCP 配置(MCP_SETUP.md) + +> Trae / Cursor 接入持久对话 MCP 插件的完整配置 + +--- + +## 1. Trae CN(macOS) + +### 配置文件位置 + +``` +~/.trae/mcp.json +``` + +### 完整配置 + +```json +{ + "mcpServers": { + "persistent-chat": { + "type": "stdio", + "command": "/Users/karuo/.persistent-chat-local/ensure-hub.sh", + "args": [ + "--workspace=${workspaceFolder}" + ], + "env": { + "PCHAT_HTTP_PORT": "13458", + "PCHAT_NODE": "/usr/local/opt/node@22/bin/node", + "PCHAT_HUB_HOST": "192.168.110.101", + "NO_PROXY": "192.168.110.101,127.0.0.1,localhost" + }, + "disabled": false, + "autoApprove": [ + "wait_for_user_input", + "init_conversation", + "merge_conversation", + "select_conversation" + ] + } + } +} +``` + +### 关键字段 + +| 字段 | 值 | 说明 | +|:---|:---|:---| +| `command` | `~/.persistent-chat-local/ensure-hub.sh` | 启动器,桥接 stdio ↔ HTTP | +| `PCHAT_HUB_HOST` | `192.168.110.101` | 远端 NAS IP(主) | +| `PCHAT_HTTP_PORT` | `13458` | Hub 端口 | +| `PCHAT_NODE` | `/usr/local/opt/node@22/bin/node` | Node 22 路径 | +| `NO_PROXY` | `192.168.110.101,127.0.0.1,localhost` | 绕过代理直连 | +| `autoApprove` | 4 个工具全开 | 不弹确认框 | + +### 加载步骤 + +1. 写入 `~/.trae/mcp.json` +2. **Trae 必须 `Cmd+Q` 完全退出**(不是关窗) +3. 重新打开 Trae +4. 看 SOLO Agent 窗口的工具列表 → 出现 `persistent-chat` 4 个工具 + +### 验证 + +```bash +# 1. 看 Trae 进程里是否有 server.js +ps aux | grep "persistent-chat-local/server.js" | grep -v grep +# 期望:看到 1 个进程,env 含 PCHAT_HUB_HOST=192.168.110.101 + +# 2. 在 Trae 里发:"开始测试" +# 期望:4 工具出现 + 拿到 ct_ 开头的 token +``` + +--- + +## 2. Cursor + +### 配置文件位置 + +``` +~/Documents/个人/.cursor/mcp.json +``` + +### 完整配置(与 Trae 相同) + +```json +{ + "mcpServers": { + "persistent-chat": { + "type": "stdio", + "command": "/Users/karuo/.persistent-chat-local/ensure-hub.sh", + "args": ["--workspace=${workspaceFolder}"], + "env": { + "PCHAT_HTTP_PORT": "13458", + "PCHAT_NODE": "/usr/local/opt/node@22/bin/node", + "PCHAT_HUB_HOST": "192.168.110.101", + "NO_PROXY": "192.168.110.101,127.0.0.1,localhost" + }, + "disabled": false, + "autoApprove": [ + "wait_for_user_input", + "init_conversation", + "merge_conversation", + "select_conversation" + ] + } + } +} +``` + +### 加载步骤 + +1. 写入 `~/Documents/个人/.cursor/mcp.json` +2. Cursor → 设置 → MCP → Refresh +3. 看 `persistent-chat` 是否绿色点 +4. 面板访问:Ctrl+L → 输入 `/mcp` + +--- + +## 3. 多 IDE 共存 + +### 场景 +- 同时开 Trae 和 Cursor,都想用持久对话 +- 共享同一个 Hub(远端 NAS) + +### 配置要点 + +**两边的 mcp.json 保持一致**,都指向同一个 Hub: + +```json +"PCHAT_HUB_HOST": "192.168.110.101" +``` + +**垂直绑定隔离**: +- 不同工作区 → 不同 session +- 不同 Cursor 标签 → 不同 session +- 同工作区 + 同标签 → 复用 session + +**冲突场景**: +- Trae 标签 = "学习" + Cursor 标签 = "学习" → 互窜(不推荐) +- 建议:Trae 用 "Trae_xxx",Cursor 用 "Cursor_xxx" + +--- + +## 4. 双 Hub 配置(远端 + 本地) + +### 场景 +- 远端 NAS 24×7 在 +- 本地 launchd 守护备份 +- 远端挂时自动 fallback + +### 方案:脚本切换 + +**ensure-hub.sh 增强版**(可加 fallback): + +```bash +#!/bin/bash +# /Users/karuo/.persistent-chat-local/ensure-hub.sh +HUB_HOST="${PCHAT_HUB_HOST:-127.0.0.1}" +# 测试主 Hub +if ! curl -sf "http://${HUB_HOST}:13458/api/health" -m 2 > /dev/null; then + # 失败,fallback 到本地 + HUB_HOST="127.0.0.1" +fi +export PCHAT_HUB_HOST="$HUB_HOST" +exec /usr/local/opt/node@22/bin/node \ + /Users/karuo/.persistent-chat-local/server.js \ + --workspace="${1#--workspace=}" +``` + +这样远端挂时自动回退到本地(launchd 守护一直跑)。 + +--- + +## 5. 工具参数详解 + +### 5.1 init_conversation + +```typescript +{ + token?: string; // 已有 token(merge 用) + cursorTitle: string; // 必填:Cursor 标签 + newPlan?: boolean; // true=换计划,归档旧 ct_ + planLabel?: string; // 计划名(newPlan 时必填) + workspace?: string; // 工作区路径 +} +``` + +**返回**: +```json +{ + "Conversation initialized. pchat-token: ct_xxxx" + "🆕 新计划线程:ct_xxxx" + "[BINDING_AUTO_NEW] 新对话已自动绑定" +} +``` + +### 5.2 select_conversation + +```typescript +{ + token: string; // 必填:要复用的 ct_ + cursorTitle: string; // 必填:当前 Cursor 标签 +} +``` + +**返回**: +```json +{ + "[BINDING_REUSE]" + "🔄 恢复对话: ct_xxxx" +} +``` + +### 5.3 wait_for_user_input + +```typescript +{ + token: string; // 必填 + message: string; // 提示用户的话 + timeout?: number; // 最长等多久(ms),默认 60000 + renewalOnly?: boolean; // true=只续约不等消息 +} +``` + +**返回**: +```json +{ + "<用户发的消息 或 '保活续跑' 默认文本>" + "[pchat-token: ct_xxxx]" + "校验#N" +} +``` + +### 5.4 merge_conversation + +```typescript +{ + fromToken: string; // 源 token + toToken: string; // 目标 token + cursorTitle: string; // 当前标签 +} +``` + +**用途**:把两个 plan 的上下文合并。 + +--- + +## 6. 完整对话循环示例 + +```javascript +// 1. 起新计划 +const init = await mcp.call('init_conversation', { + cursorTitle: '持久对话永久续跑-20260626', + newPlan: true, + planLabel: '永久续跑三段式', +}); +const token = extractToken(init); // ct_xxxx + +// 2. 干活(可任意调用其他工具) +// ... + +// 3. 真挂起等用户 +const reply = await mcp.call('wait_for_user_input', { + token, + message: '【真挂起】请发任务或"继续"', + timeout: 1800000, // 30 分钟 +}); +// 收到用户消息 → reply.text + +// 4. 复用旧 session +await mcp.call('select_conversation', { + token, + cursorTitle: '持久对话永久续跑-20260626', +}); + +// 5. 循环到用户说"结束持久对话" +``` + +--- + +## 7. 常见配置错误 + +| 错误 | 现象 | 修复 | +|:---|:---|:---| +| `PCHAT_NODE` 路径错 | `node not found` | `which node` 改 | +| `PCHAT_HUB_HOST` 用 0.0.0.0 | 端口冲突 | 改成具体 IP | +| `NO_PROXY` 缺 | 代理拦截 | 加 `192.168.110.101` | +| `autoApprove` 缺 | 每次弹窗 | 加 4 个工具名 | +| Trae 没重启 | MCP 不加载 | `Cmd+Q` 退出 | +| Cursor 没 refresh | 看不到 | 设置 → MCP → Refresh | + +--- + +> 最后更新:2026-06-26 · 卡若AI diff --git a/docs/QUICKSTART.md b/docs/QUICKSTART.md new file mode 100644 index 0000000..344438c --- /dev/null +++ b/docs/QUICKSTART.md @@ -0,0 +1,161 @@ +# 5 分钟上手(QUICKSTART.md) + +> 假设你的工作环境是 macOS + Trae CN + 公司 NAS(192.168.110.101) +> 跟着抄,5 分钟内跑起来。 + +--- + +## Step 1 · 装到本地(30s) + +```bash +mkdir -p ~/.persistent-chat-local + +# 从仓库拷核心文件 +git clone http://192.168.110.101:3000/fnvtk/persistent-chat-plugin.git /tmp/pchat +cp /tmp/pchat/src/* ~/.persistent-chat-local/ +cp /tmp/pchat/web/panel.html ~/.persistent-chat-local/ +cp /tmp/pchat/scripts/ensure-hub.sh ~/.persistent-chat-local/ +chmod +x ~/.persistent-chat-local/ensure-hub.sh +``` + +## Step 2 · 写 MCP 配置(30s) + +```bash +cat > ~/.trae/mcp.json <<'EOF' +{ + "mcpServers": { + "persistent-chat": { + "type": "stdio", + "command": "/Users/karuo/.persistent-chat-local/ensure-hub.sh", + "args": ["--workspace=${workspaceFolder}"], + "env": { + "PCHAT_HTTP_PORT": "13458", + "PCHAT_NODE": "/usr/local/opt/node@22/bin/node", + "PCHAT_HUB_HOST": "192.168.110.101", + "NO_PROXY": "192.168.110.101,127.0.0.1,localhost" + }, + "disabled": false, + "autoApprove": [ + "wait_for_user_input", + "init_conversation", + "merge_conversation", + "select_conversation" + ] + } + } +} +EOF +``` + +## Step 3 · 配 Hub(远端 NAS,已有则跳过) + +在 NAS 上(ssh fnvtk@192.168.110.101): + +```bash +mkdir -p /volume1/homes/fnvtk/.persistent-chat-local +cd /volume1/homes/fnvtk/.persistent-chat-local +# scp 把 src/* + web/panel.html 拷过来 +chmod +x ensure-hub.sh + +# 启动 +nohup /usr/local/opt/node@22/bin/node hub.js > logs/hub.log 2>&1 & +disown +``` + +## Step 4 · 配本地备份 Hub(30s) + +```bash +cat > ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist <<'EOF' + + + + + Labelcom.karuo.persistent-chat-hub + ProgramArguments + + /usr/local/opt/node@22/bin/node + /Users/karuo/.persistent-chat-local/hub.js + + WorkingDirectory/Users/karuo/.persistent-chat-local + RunAtLoad + KeepAlive + StandardOutPath/Users/karuo/.persistent-chat-local/logs/launchd.log + StandardErrorPath/Users/karuo/.persistent-chat-local/logs/launchd.err + + +EOF + +launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.karuo.persistent-chat-hub.plist +``` + +## Step 5 · Trae Cmd+Q 重开(10s) + +完全退出 Trae(不是关窗),重开。 + +## Step 6 · 验证(30s) + +打开 Trae → SOLO Agent 窗口 → 看到 4 个工具: + +``` +✓ init_conversation +✓ select_conversation +✓ wait_for_user_input +✓ merge_conversation +``` + +发:"**开始测试**" + +期望: +1. 看到 `init_conversation` 调出 +2. 拿到 `ct_xxxx` 的 token +3. 收到 [卡若复盘] 格式回复 +4. Agent 继续 wait + +## Step 7 · 跑三段式(持续) + +每次用户发新消息: + +``` +1. select_conversation(token=ct_xxx) ← 接回旧 session +2. 执行用户任务 +3. 写 [卡若复盘] 🎯📌💡📝▶ +4. wait_for_user_input(token=ct_xxx, message=提示) +5. 循环 +``` + +## Step 8 · 结束(仅在用户说"结束持久对话"时) + +```javascript +// 用户说"结束持久对话"后 +await mcp.call('merge_conversation', { + fromToken: 'ct_xxx', + toToken: 'ct_归档', // 或留空 = 纯归档 + cursorTitle: '当前标签', +}); +``` + +--- + +## 🎉 完成 + +你现在有: +- ✅ 远端 NAS Hub 24×7 在 +- ✅ 本地备份 Hub(挂了自动起) +- ✅ Trae 插件 4 工具全可用 +- ✅ 持久对话循环(不主动结束) + +--- + +## 🆘 卡住看这里 + +| 现象 | 一行修 | +|:---|:---| +| Trae 看不到 4 工具 | `Cmd+Q` 重开 | +| Hub 启动失败 | `node hub.js` 前台跑看错 | +| 提示 Token未获取 | Trae 没重启 | +| 任务完成 = 对话死 | 不是,Hub 仍 holding,下一条进来自动续 | +| wait 不返回 | 真挂起模式生效,等你发消息 | + +--- + +> 详细见 [DEPLOY.md](DEPLOY.md) / [MCP_SETUP.md](MCP_SETUP.md) / [ARCHITECTURE.md](ARCHITECTURE.md) diff --git a/mcp-config/cursor.mcp.json b/mcp-config/cursor.mcp.json new file mode 100644 index 0000000..b0c5c59 --- /dev/null +++ b/mcp-config/cursor.mcp.json @@ -0,0 +1,37 @@ +{ + "mcpServers": { + "codegraph": { + "type": "stdio", + "command": "/Users/karuo/Documents/个人/卡若AI/04_卡火(火)/火种_知识模型/CodeGraph_MCP语义代码图谱/脚本/codegraph_mcp_serve.sh", + "args": [ + "${workspaceFolder}" + ], + "disabled": false, + "autoApprove": [ + "codegraph_search", + "codegraph_callers", + "codegraph_callees", + "codegraph_impact", + "codegraph_node", + "codegraph_context", + "codegraph_explore", + "codegraph_files", + "codegraph_status" + ] + }, + "persistent-chat": { + "command": "node", + "args": [ + "/Users/karuo/.cursor-loop/bridge.js", + "--workspace=/Users/karuo/Documents/个人" + ], + "disabled": false, + "autoApprove": [ + "wait_for_user_input", + "init_conversation", + "merge_conversation", + "select_conversation" + ] + } + } +} \ No newline at end of file diff --git a/mcp-config/trae.mcp.json b/mcp-config/trae.mcp.json new file mode 100644 index 0000000..f79900c --- /dev/null +++ b/mcp-config/trae.mcp.json @@ -0,0 +1,25 @@ +{ + "mcpServers": { + "persistent-chat": { + "type": "stdio", + "command": "/Users/karuo/.persistent-chat-local/ensure-hub.sh", + "args": [ + "--workspace=${workspaceFolder}" + ], + "env": { + "PCHAT_HTTP_PORT": "13458", + "PCHAT_NODE": "/usr/local/opt/node@22/bin/node", + "PCHAT_HUB_HOST": "192.168.110.101", + "NO_PROXY": "192.168.110.101,127.0.0.1,localhost", + "no_proxy": "192.168.110.101,127.0.0.1,localhost" + }, + "disabled": false, + "autoApprove": [ + "wait_for_user_input", + "init_conversation", + "merge_conversation", + "select_conversation" + ] + } + } +} diff --git a/scripts/ensure-hub.sh b/scripts/ensure-hub.sh new file mode 100755 index 0000000..774ddf3 --- /dev/null +++ b/scripts/ensure-hub.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# 启动 MCP 前确保 Hub 在 13458 运行(全局唯一) +set -eo pipefail +INSTALL="$(cd "$(dirname "$0")" && pwd)" +PORT="${PCHAT_HTTP_PORT:-13458}" +LOG="$INSTALL/logs/hub.log" +NODE="${PCHAT_NODE:-$(command -v node 2>/dev/null || true)}" +[[ -z "$NODE" ]] && NODE="/usr/local/opt/node@22/bin/node" + +mkdir -p "$INSTALL/logs" + +if [[ ! -x "$NODE" ]]; then + echo "[ensure-hub] FATAL: node not found: $NODE" >&2 + exit 1 +fi + +hub_health() { + curl -sf -m 2 "http://127.0.0.1:${PORT}/api/health" >/dev/null 2>&1 +} + +start_hub() { + if hub_health; then return 0; fi + echo "[ensure-hub] starting hub on :${PORT}" >> "$LOG" + nohup "$NODE" "$INSTALL/hub.js" >> "$INSTALL/logs/hub.stdout.log" 2>&1 & + local i=0 + while [[ $i -lt 80 ]]; do + hub_health && return 0 + sleep 0.15 + i=$((i + 1)) + done + return 1 +} + +if ! start_hub; then + echo "[ensure-hub] FATAL: Hub not ready on 127.0.0.1:${PORT} (waited ~12s). Run: bash 卡若AI/.../Cursor持久对话/脚本/sync-runtime.sh" >&2 + exit 1 +fi + +# Cursor 无工作区时 ${workspaceFolder} 可能为空/none/未替换字面量 → 兜底 HOME +ARGS=() +for a in "$@"; do + case "$a" in + --workspace=|--workspace=none|--workspace=\$\{workspaceFolder\}) + ARGS+=("--workspace=$HOME") + ;; + --workspace=*) + ARGS+=("$a") + ;; + *) + ARGS+=("$a") + ;; + esac +done +if [[ ${#ARGS[@]} -eq 0 ]] || ! printf '%s\n' "${ARGS[@]}" | grep -q '^--workspace='; then + ARGS+=("--workspace=${PWD:-$HOME}") +fi + +exec "$NODE" "$INSTALL/server.js" "${ARGS[@]}" diff --git a/scripts/mongo_sync.py b/scripts/mongo_sync.py new file mode 100755 index 0000000..5a72857 --- /dev/null +++ b/scripts/mongo_sync.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""持久对话会话 → MongoDB karuo_site.持久对话会话(本地 ~/.persistent-chat-local/sessions.json)""" +import json +import os +import sys +from datetime import datetime, timezone + +HOME = os.path.expanduser("~") +SESSIONS_FILE = os.path.join(HOME, ".persistent-chat-local", "sessions.json") +MONGO_URI = os.environ.get("MONGO_URI", "mongodb://127.0.0.1:27017") +DB_NAME = os.environ.get("MONGO_DB", "karuo_site") +COLL_NAME = "持久对话会话" + + +def main() -> int: + if not os.path.isfile(SESSIONS_FILE): + print("SKIP: no sessions.json") + return 0 + try: + from pymongo import MongoClient + except ImportError: + print("SKIP: pymongo not installed (pip3 install pymongo)") + return 0 + + with open(SESSIONS_FILE, encoding="utf-8") as f: + data = json.load(f) + + client = MongoClient(MONGO_URI, serverSelectionTimeoutMS=4000) + client.admin.command("ping") + col = client[DB_NAME][COLL_NAME] + col.create_index("token", unique=True) + col.create_index("workspace") + col.create_index("lastActiveAt") + + now = datetime.now(timezone.utc) + n = 0 + for token, sess in data.items(): + doc = dict(sess) + doc["token"] = token + doc["mongo_sync_at"] = now + col.update_one({"token": token}, {"$set": doc}, upsert=True) + n += 1 + print(f"OK: synced {n} sessions → {DB_NAME}.{COLL_NAME}") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as e: + print(f"FAIL: {e}", file=sys.stderr) + sys.exit(1) diff --git a/src/cursor-title.js b/src/cursor-title.js new file mode 100755 index 0000000..b310189 --- /dev/null +++ b/src/cursor-title.js @@ -0,0 +1,141 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const crypto = require('crypto'); + +const HOME = process.env.HOME || process.env.USERPROFILE || ''; +const VSCDB = path.join( + HOME, + 'Library/Application Support/Cursor/User/globalStorage/state.vscdb' +); +const LOOP_DIR = path.join(HOME, '.cursor-loop'); +const ACTIVE_VERTICAL = path.join(LOOP_DIR, 'active-vertical.json'); + +function normTitle(title) { + const t = String(title || '').trim(); + return t || '(未命名标签)'; +} + +function readActiveVertical() { + try { + const raw = fs.readFileSync(ACTIVE_VERTICAL, 'utf8'); + const j = JSON.parse(raw); + if (j && j.title) return j; + } catch {} + return null; +} + +function writeActiveVertical(payload) { + try { + fs.mkdirSync(LOOP_DIR, { recursive: true }); + fs.writeFileSync( + ACTIVE_VERTICAL, + JSON.stringify({ ...payload, updatedAt: Date.now() }, null, 2) + ); + } catch {} +} + +function queryVscdb(sql) { + if (!fs.existsSync(VSCDB)) return null; + try { + return execFileSync('sqlite3', [VSCDB, sql], { encoding: 'utf8', timeout: 3000 }).trim(); + } catch { + return null; + } +} + +function loadComposerHeaders() { + const raw = queryVscdb( + "SELECT value FROM ItemTable WHERE key='composer.composerHeaders' LIMIT 1;" + ); + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return null; + } +} + +function pickComposer(headers, opts = {}) { + const list = headers?.allComposers || []; + const composerId = + opts.composerId || + process.env.CURSOR_CONVERSATION_ID || + process.env.CURSOR_CHAT_ID || + process.env.COMPOSER_ID || + readActiveVertical()?.composerId; + + if (composerId) { + const hit = list.find((c) => c.composerId === composerId); + if (hit) return hit; + } + + const wsHint = String(opts.workspace || '').replace(/\/+$/, ''); + const candidates = list.filter((c) => c.type === 'head' && !c.isArchived); + if (wsHint) { + const wsMatch = candidates.filter((c) => { + const cfg = c.workspaceIdentifier?.configPath; + const fp = cfg?.fsPath || cfg?.path || ''; + return fp && (wsHint.includes(fp) || fp.includes(wsHint)); + }); + if (wsMatch.length) { + wsMatch.sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0)); + return wsMatch[0]; + } + } + candidates.sort((a, b) => (b.lastUpdatedAt || 0) - (a.lastUpdatedAt || 0)); + return candidates[0] || null; +} + +/** 解析当前 Cursor Agent 标签名(真源:args > threadLock > active-vertical > vscdb) */ +function resolveCursorTabTitle(opts = {}) { + const fromArgs = opts.cursorTitle || opts.title; + if (fromArgs && normTitle(fromArgs) !== '(未命名标签)') { + return { title: normTitle(fromArgs), source: 'args', composerId: opts.composerId || null }; + } + + const active = readActiveVertical(); + if (active?.title && normTitle(active.title) !== '(未命名标签)') { + const age = Date.now() - (active.updatedAt || 0); + if (age < 30 * 60 * 1000) { + return { + title: normTitle(active.title), + source: 'active-vertical', + composerId: active.composerId || null, + }; + } + } + + const envTitle = process.env.CURSOR_CHAT_TITLE || process.env.PCHAT_CURSOR_TITLE; + if (envTitle) { + return { title: normTitle(envTitle), source: 'env', composerId: null }; + } + + const headers = loadComposerHeaders(); + const composer = pickComposer(headers, opts); + if (composer?.name) { + const payload = { + composerId: composer.composerId, + title: normTitle(composer.name), + workspace: composer.workspaceIdentifier?.configPath?.fsPath || opts.workspace || '', + }; + writeActiveVertical(payload); + return { + title: payload.title, + source: 'vscdb', + composerId: composer.composerId, + }; + } + + return { title: '', source: 'missing', composerId: null }; +} + +module.exports = { + resolveCursorTabTitle, + writeActiveVertical, + readActiveVertical, + ACTIVE_VERTICAL, + normTitle, +}; diff --git a/src/hub.js b/src/hub.js new file mode 100755 index 0000000..627c2a6 --- /dev/null +++ b/src/hub.js @@ -0,0 +1,2386 @@ +#!/usr/bin/env node +/** + * Persistent Chat Hub — 唯一 HTTP 进程(端口 13458) + * 所有 Cursor 工作区的 MCP 进程共享本 Hub,避免多进程抢端口导致面板回复进错进程。 + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const http = require('http'); +const crypto = require('crypto'); +const os = require('os'); +const { URL } = require('url'); + +const HOME = process.env.HOME || process.env.USERPROFILE; +const ROOT = path.join(HOME, '.persistent-chat-local'); +const LOG_DIR = path.join(ROOT, 'logs'); +const SESSIONS_FILE = path.join(ROOT, 'sessions.json'); +const BINDINGS_FILE = path.join(ROOT, 'bindings.json'); +const PLAN_BINDINGS_FILE = path.join(ROOT, 'plan-bindings.json'); +const THREAD_BINDINGS_DIR = path.join(ROOT, 'thread-bindings'); +const PANEL_HTML = path.join(__dirname, 'panel.html'); +const HTTP_PORT = parseInt(process.env.PCHAT_HTTP_PORT || '13458', 10); +const POLL_TIMEOUT_MS = 10000; +const CONNECTION_KEEPALIVE_MS = 10000; +/** 超过此时长无保活且未挂起 → 归档到 archive/ */ +const STALE_KEEPALIVE_MS = 2 * 60 * 60 * 1000; +const ARCHIVE_DIR = path.join(ROOT, 'archive'); +const SETTINGS_FILE = path.join(ROOT, 'settings.json'); +const DEFAULT_SETTINGS = { + autoContinue: true, + /** 全局默认续跑模式(单会话可覆盖):standard=复盘 | turbo=直通 | optimize=优化 */ + autoContinueMode: 'standard', + /** 单会话默认:自动续跑开、复盘模式 */ + defaultSessionAutoContinue: true, + defaultContinueMode: 'standard', + autoContinueDelayMs: 10000, + autoContinueText: '继续', + turboContinueText: '【直通续跑】继续执行当前计划,直推开发直到目标完成,无需等复盘/追问/检索确认。', + autoContinueMaxStreak: 0, + /** 定时回复:0=自动(仅走 autoContinue 规则);>0 为固定间隔 */ + timedReplyEnabled: true, + defaultTimedReplyEnabled: true, + timedReplyIntervalMs: 10000, + connectionKeepAliveMs: 10000, + timedReplyPresets: [0, 10000, 30000, 60000, 120000, 240000, 540000, 900000, 1800000], + timedReplyText: '【定时回复】保持持久对话连接,若仍有任务请继续执行。', + timedReplyTurboText: '【定时续跑】保持连接,继续执行当前计划,直推开发直到目标完成。', + /** 为 true 时:▶=无 / 100% 仍发保活续跑,不因 idle 停 scheduleAutoContinue */ + alwaysKeepAlive: true, + keepAliveText: '【保活续跑】保持持久对话连接,继续执行当前任务与学习清单,按复盘▶推进,勿停。', + keepAliveTurboText: '【保活续跑】保持连接,继续执行当前计划与学习清单,直推开发直到目标完成。', + maxSessions: 30, + maxIdleAgeDays: 14, +}; + +for (const d of [ROOT, LOG_DIR, ARCHIVE_DIR]) { + try { fs.mkdirSync(d, { recursive: true }); } catch {} +} + +const logFile = path.join(LOG_DIR, 'hub.log'); +function log(...parts) { + const line = `[${new Date().toISOString()}] ${parts.map((p) => (typeof p === 'string' ? p : JSON.stringify(p))).join(' ')}\n`; + try { fs.appendFileSync(logFile, line); } catch {} +} + +const sessions = new Map(); +/** token -> { message, prompt, title, createdAt, workspace, pid, pollers, autoTimer?, timedReplyTimer?, timedReplyFireAt? } */ +const pendingWaits = new Map(); +const TIMED_REPLY_MIN_MS = 5000; +/** 全局唯一:只有这个 token 允许自动续跑,避免多标签乱继续 */ +let activeWaitToken = null; +let hubSettings = { ...DEFAULT_SETTINGS }; +/** bindingKey -> { token, updatedAt } 一会话一线程 权威映射 */ +let bindingRegistry = {}; +/** planBindingKey (ws::title@ip#plan) -> { token, planLabel, cursorTitle, updatedAt } */ +let planRegistry = {}; + +function normPlanLabel(label) { + const t = String(label || '').trim(); + return t || '(默认计划)'; +} + +function normalizeExactToken(raw) { + const t = String(raw || '').trim(); + const m = t.match(/^(ct_[0-9a-f]{8})$/i); + return m ? m[1].toLowerCase() : null; +} + +function makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel) { + return `${makeBindingKey(workspace, cursorTitle, hostIp)}#${normPlanLabel(planLabel)}`; +} + +function loadPlanRegistry() { + try { + planRegistry = JSON.parse(fs.readFileSync(PLAN_BINDINGS_FILE, 'utf8')); + } catch { + planRegistry = {}; + } +} + +function savePlanRegistry() { + try { fs.writeFileSync(PLAN_BINDINGS_FILE, JSON.stringify(planRegistry, null, 2)); } catch (e) { + log('plan-bindings save failed', String(e.message || e)); + } +} + +function registerPlanBinding(workspace, cursorTitle, hostIp, planLabel, token) { + if (!cursorTitle || !token) return; + const pk = makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel); + planRegistry[pk] = { + token, + planLabel: normPlanLabel(planLabel), + cursorTitle: normTitle(cursorTitle), + workspace: normWorkspace(workspace), + hostIp: normHostIp(hostIp), + updatedAt: nowMs(), + }; + planRegistry[token] = { planBindingKey: pk, updatedAt: nowMs() }; + savePlanRegistry(); + const s = sessions.get(token); + if (s) { + s.planLabel = normPlanLabel(planLabel); + s.planBindingKey = pk; + } +} + +function getTokenByPlan(workspace, cursorTitle, hostIp, planLabel) { + const pk = makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel); + const hit = planRegistry[pk]; + if (!hit || !hit.token) return null; + const tok = normalizeExactToken(hit.token); + if (!tok || !sessions.has(tok) || sessions.get(tok).status === 'completed') return null; + return tok; +} + +function getPlanByToken(token) { + const tok = normalizeExactToken(token); + if (!tok) return null; + const rev = planRegistry[tok]; + if (rev && rev.planBindingKey && planRegistry[rev.planBindingKey]) { + return planRegistry[rev.planBindingKey]; + } + const s = sessions.get(tok); + if (!s) return null; + return { + token: tok, + planLabel: s.planLabel || normPlanLabel(s.title), + cursorTitle: s.cursorTitle || s.title || '', + workspace: s.workspace || '', + }; +} + +function loadBindingRegistry() { + try { + bindingRegistry = JSON.parse(fs.readFileSync(BINDINGS_FILE, 'utf8')); + } catch { + bindingRegistry = {}; + } +} + +function saveBindingRegistry() { + try { fs.writeFileSync(BINDINGS_FILE, JSON.stringify(bindingRegistry, null, 2)); } catch (e) { + log('bindings save failed', String(e.message || e)); + } +} + +function getHostIp() { + const ifs = os.networkInterfaces(); + for (const name of Object.keys(ifs)) { + for (const iface of ifs[name] || []) { + if (iface.family === 'IPv4' && !iface.internal) return iface.address; + } + } + return '127.0.0.1'; +} + +function hubEndpointLabel() { + return `127.0.0.1:${HTTP_PORT}`; +} + +function normHostIp(ip) { + const t = String(ip || '').trim(); + return t || getHostIp(); +} + +function legacyBindingKey(workspace, cursorTitle) { + return `${normWorkspace(workspace)}::${normTitle(cursorTitle)}`; +} + +function makeBindingKey(workspace, cursorTitle, hostIp) { + const ip = normHostIp(hostIp); + return `${legacyBindingKey(workspace, cursorTitle)}@${ip}`; +} + +function lookupRegistry(workspace, cursorTitle, hostIp) { + if (!cursorTitle) return null; + const key = makeBindingKey(workspace, cursorTitle, hostIp); + const hit = bindingRegistry[key]; + if (hit && hit.token) return { key, token: hit.token, hostIp: hit.hostIp || ipFromKey(key), hub: hit.hub || '' }; + const leg = legacyBindingKey(workspace, cursorTitle); + const old = bindingRegistry[leg]; + if (old && old.token) return { key: leg, token: old.token, legacy: true, hostIp: old.hostIp || '', hub: old.hub || '' }; + return null; +} + +function ipFromKey(key) { + const at = String(key || '').lastIndexOf('@'); + return at >= 0 ? key.slice(at + 1) : ''; +} + +function registerBinding(workspace, cursorTitle, token, meta = {}) { + if (!cursorTitle || !token) return; + const hostIp = normHostIp(meta.hostIp); + const hub = meta.hub || hubEndpointLabel(); + const key = makeBindingKey(workspace, cursorTitle, hostIp); + bindingRegistry[key] = { token, hostIp, hub, updatedAt: nowMs() }; + const leg = legacyBindingKey(workspace, cursorTitle); + if (leg !== key && bindingRegistry[leg]) delete bindingRegistry[leg]; + saveBindingRegistry(); + const s = sessions.get(token); + if (s) { + s.bindingKey = key; + s.cursorTitle = cursorTitle; + s.workspace = workspace || s.workspace; + s.hostIp = hostIp; + s.hub = hub; + s.bindingLockedAt = s.bindingLockedAt || nowMs(); + s.bindingCheckCount = (s.bindingCheckCount || 0) + 1; + const pl = meta.planLabel || s.planLabel || s.title; + registerPlanBinding(workspace, cursorTitle, hostIp, pl, token); + } +} + +function getRegistryToken(workspace, cursorTitle, hostIp) { + const hit = lookupRegistry(workspace, cursorTitle, hostIp); + return hit ? hit.token : null; +} + +function getTokenOwnerTitle(workspace, token) { + if (!token) return null; + const s = sessions.get(token); + if (s?.cursorTitle) return normTitle(s.cursorTitle); + const ws = normWorkspace(workspace); + const prefix = `${ws}::`; + for (const [key, hit] of Object.entries(bindingRegistry)) { + if (hit && hit.token === token && key.startsWith(prefix)) { + let rest = key.slice(prefix.length); + const at = rest.lastIndexOf('@'); + if (at >= 0) rest = rest.slice(0, at); + return normTitle(rest); + } + } + return null; +} + +/** Cursor 标签改名:同 ct_ 迁移到新标题,删除旧 bindingKey */ +function migrateBindingRename(workspace, token, newTitle, hostIp) { + if (!token || !newTitle) return { ok: false }; + const oldTitle = getTokenOwnerTitle(workspace, token); + const newNorm = normTitle(newTitle); + if (!oldTitle || oldTitle === newNorm) return { ok: false, renamed: false }; + const ws = normWorkspace(workspace); + const ip = normHostIp(hostIp); + const prefix = `${ws}::`; + for (const key of Object.keys(bindingRegistry)) { + if (!key.startsWith(prefix)) continue; + if (bindingRegistry[key]?.token === token) delete bindingRegistry[key]; + } + const s = sessions.get(token); + const hub = (s && s.hub) || hubEndpointLabel(); + registerBinding(workspace, newTitle, token, { hostIp: ip, hub }); + if (s) { + s.cursorTitle = newTitle; + s.title = newTitle; + s.bindingRenamedFrom = oldTitle; + s.bindingRenamedAt = nowMs(); + } + saveBindingRegistry(); + saveSessions(); + log('binding rename', { token, from: oldTitle, to: newNorm, hostIp: ip }); + return { ok: true, renamed: true, from: oldTitle, to: newNorm }; +} + +function buildBindingContext(workspace, cursorTitle, hostIp, planLabel) { + const ip = normHostIp(hostIp); + const titleNorm = normTitle(cursorTitle); + let regToken = null; + if (planLabel) { + regToken = getTokenByPlan(workspace, cursorTitle, ip, planLabel); + } + if (!regToken) { + const hit = lookupRegistry(workspace, cursorTitle, ip); + regToken = hit ? hit.token : null; + } + const wsNorm = normWorkspace(workspace); + const active = []; + for (const [token, s] of sessions.entries()) { + if (s.status === 'completed') continue; + if (normWorkspace(s.workspace) !== wsNorm) continue; + const st = normTitle(s.cursorTitle || s.title || ''); + if (titleNorm && titleNorm !== '(未命名标签)' && st !== titleNorm) continue; + active.push({ + token, + title: s.title || s.cursorTitle || '', + cursorTitle: s.cursorTitle || s.title || '', + planLabel: s.planLabel || '', + hostIp: s.hostIp || '', + hub: s.hub || '', + isActiveWait: token === activeWaitToken, + status: pendingWaits.has(token) ? 'waiting' : (s.status || 'idle'), + }); + } + const mode = regToken ? 'reuse' : (active.length > 0 ? 'choice' : 'auto_new'); + return { + mode, + hostIp: ip, + hub: hubEndpointLabel(), + hubUrl: `http://${hubEndpointLabel()}/`, + bindingKey: cursorTitle ? makeBindingKey(workspace, cursorTitle, ip) : '', + planBindingKey: planLabel ? makePlanBindingKey(workspace, cursorTitle, ip, planLabel) : '', + boundToken: regToken, + planLabel: planLabel ? normPlanLabel(planLabel) : '', + activeSessions: active, + }; +} + +function validateTokenBinding(token, workspace, cursorTitle, hostIp, opts = {}) { + if (!cursorTitle || normTitle(cursorTitle) === '(未命名标签)') { + return { ok: true }; + } + const titleNorm = normTitle(cursorTitle); + const ipNorm = normHostIp(hostIp); + const key = makeBindingKey(workspace, cursorTitle, ipNorm); + const s = sessions.get(token); + if (!s) { + return { + ok: false, + error: 'UNKNOWN_CONVERSATION_TOKEN', + bindingKey: key, + got: token, + hint: `完整 token ${token} 不在 Hub 会话库/绑定库中;找不到时才允许新建计划。`, + }; + } + if (s?.hostIp && normHostIp(s.hostIp) !== ipNorm) { + return { + ok: false, + error: 'HOST_IP_MISMATCH', + bindingKey: key, + expectedHostIp: s.hostIp, + gotHostIp: ipNorm, + hint: `ct_${token.slice(3)} 绑定在 IP ${s.hostIp},当前连接 ${ipNorm},禁止跨机串线。`, + }; + } + const ownerTitle = getTokenOwnerTitle(workspace, token); + if (ownerTitle && ownerTitle !== titleNorm) { + if (opts.allowTitleRename) { + const mig = migrateBindingRename(workspace, token, cursorTitle, ipNorm); + if (mig.renamed) { + return { ok: true, titleRenamed: true, from: mig.from, to: mig.to }; + } + } + return { + ok: false, + error: 'TOKEN_BOUND_TO_OTHER_TITLE', + bindingKey: key, + tokenOwnerTitle: ownerTitle, + gotTitle: titleNorm, + expected: getRegistryToken(workspace, cursorTitle, ipNorm) || '(未绑定,请 init)', + got: token, + hint: `ct_${token.slice(3)} 已绑定标签「${ownerTitle}」,禁止在「${cursorTitle}」使用。请 init_conversation(cursorTitle=当前标签名, newPlan:true)。`, + }; + } + const regToken = getRegistryToken(workspace, cursorTitle, ipNorm); + const plan = getPlanByToken(token); + const planOwnsToken = !!( + plan && + plan.token === token && + normWorkspace(plan.workspace) === normWorkspace(workspace) && + normTitle(plan.cursorTitle) === titleNorm + ); + if (regToken && regToken !== token && !planOwnsToken) { + return { + ok: false, + error: 'WRONG_TOKEN_FOR_BINDING', + bindingKey: key, + expected: regToken, + got: token, + hint: `标签「${cursorTitle}」已锁定 ${regToken},禁止串用 ${token};若这是旧计划,请带完整 token 且保持 planLabel 绑定。`, + }; + } + if (s?.bindingKey && s.bindingKey !== key && s.bindingKey !== legacyBindingKey(workspace, cursorTitle)) { + return { + ok: false, + error: 'TOKEN_BINDING_MISMATCH', + expected: s.bindingKey, + got: key, + hint: `ct_${token.slice(3)} 属于其它标签,本标签应使用 ${regToken || '对应 ct_'}`, + }; + } + if (s) { + s.bindingCheckCount = (s.bindingCheckCount || 0) + 1; + s.lastBindingCheckAt = nowMs(); + } + return { + ok: true, + bindingKey: key, + hostIp: ipNorm, + hub: hubEndpointLabel(), + bindingUnchanged: true, + bindingCheckCount: s?.bindingCheckCount || 0, + }; +} + +function normWorkspace(ws) { + return String(ws || '').replace(/\/+$/, '') || '(unknown)'; +} + +function normTitle(title) { + const t = String(title || '').trim(); + return t || '(未命名标签)'; +} + +function findBoundSession(workspace, cursorTitle, hostIp, planLabel) { + if (!cursorTitle) return null; + if (planLabel) { + const byPlan = getTokenByPlan(workspace, cursorTitle, hostIp, planLabel); + if (byPlan) return byPlan; + } + const reg = getRegistryToken(workspace, cursorTitle, hostIp); + if (reg) { + const tok = normalizeExactToken(reg); + if (tok && sessions.has(tok) && sessions.get(tok).status !== 'completed') return tok; + } + return null; +} + +/** 仅标记 MCP 最近挂起的 ct_;不清其它会话的续跑/定时器(多标签并行保活) */ +function setActiveWaitToken(token) { + activeWaitToken = token; + log('activeWaitToken', token); +} + +function parseTitleFromBindingKey(key) { + const at = String(key || '').lastIndexOf('@'); + const left = at >= 0 ? key.slice(0, at) : key; + const idx = left.indexOf('::'); + return idx >= 0 ? normTitle(left.slice(idx + 2)) : normTitle(left); +} + +function parseWorkspaceFromBindingKey(key) { + const at = String(key || '').lastIndexOf('@'); + const left = at >= 0 ? key.slice(0, at) : key; + const idx = left.indexOf('::'); + return idx >= 0 ? normWorkspace(left.slice(0, idx)) : '(unknown)'; +} + +/** bindings.json 有、sessions 无 → 恢复占位会话,避免面板丢 ct_、乱绑 */ +/** 从 thread-bindings/*.json 回填丢失的 bindings.json(防误删/多进程写丢) */ +function rebuildBindingRegistryFromThreadFiles() { + if (!fs.existsSync(THREAD_BINDINGS_DIR)) return 0; + let n = 0; + for (const name of fs.readdirSync(THREAD_BINDINGS_DIR)) { + if (!name.endsWith('.json')) continue; + try { + const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BINDINGS_DIR, name), 'utf8')); + const token = hit && hit.token; + const key = hit && hit.bindingKey; + if (!token || !key) continue; + const cur = bindingRegistry[key]; + if (cur && cur.token && cur.token !== token) continue; + if (!cur || cur.token === token) { + bindingRegistry[key] = { + token, + hostIp: hit.hostIp || ipFromKey(key) || getHostIp(), + hub: hit.hub || hubEndpointLabel(), + updatedAt: hit.updatedAt || nowMs(), + }; + n++; + } + } catch (e) { + log('thread-binding read skip', name, String(e.message || e)); + } + } + if (n > 0) { + saveBindingRegistry(); + log('rebuild bindings from thread files', { addedOrUpdated: n }); + } + return n; +} + +function isVerticallyLockedToken(token) { + if (!token || !fs.existsSync(THREAD_BINDINGS_DIR)) return false; + try { + for (const name of fs.readdirSync(THREAD_BINDINGS_DIR)) { + if (!name.endsWith('.json')) continue; + const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BINDINGS_DIR, name), 'utf8')); + if (hit && hit.token === token && hit.verticalLock) return true; + } + } catch {} + return false; +} + +function restoreSessionsFromBindings() { + rebuildBindingRegistryFromThreadFiles(); + let n = 0; + for (const [key, hit] of Object.entries(bindingRegistry)) { + if (!hit || !hit.token) continue; + if (sessions.has(hit.token)) continue; + const title = parseTitleFromBindingKey(key); + const workspace = parseWorkspaceFromBindingKey(key); + const hostIp = hit.hostIp || ipFromKey(key) || getHostIp(); + sessions.set(hit.token, { + token: hit.token, + title, + planLabel: '', + cursorTitle: title, + bindingKey: key, + hostIp, + hub: hit.hub || hubEndpointLabel(), + workspace, + status: 'idle', + phase: 'running', + preRun: null, + optimizeRun: null, + sessionAutoContinue: hubSettings.defaultSessionAutoContinue !== false, + continueMode: normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode), + timedReplyEnabled: hubSettings.defaultTimedReplyEnabled !== false, + timedReplyIntervalMs: hubSettings.timedReplyIntervalMs != null ? hubSettings.timedReplyIntervalMs : 0, + bindingCheckCount: 0, + bindingLockedAt: hit.updatedAt || nowMs(), + createdAt: hit.updatedAt || nowMs(), + lastActiveAt: hit.updatedAt || nowMs(), + messages: [], + restoredFromBinding: true, + connectionHeld: false, + status: 'idle', + }); + n++; + log('restore session from binding', hit.token, key); + } + if (n > 0) saveSessions(); + return n; +} + +function registryTokenForSession(token, s) { + if (!s) return token; + const key = s.bindingKey || makeBindingKey(s.workspace, s.cursorTitle || s.title, s.hostIp); + const byKey = bindingRegistry[key]?.token; + if (byKey) return byKey; + return getRegistryToken(s.workspace, s.cursorTitle || s.title, s.hostIp) || token; +} + +function bindingOkForSession(token, s) { + if (!s) return true; + const reg = registryTokenForSession(token, s); + return reg === token; +} + +function clearActiveWaitToken(token) { + if (activeWaitToken === token) activeWaitToken = null; +} + +function dedupeBindingSessions() { + const groups = new Map(); + for (const [token, s] of sessions.entries()) { + if (s.status === 'completed') continue; + if (pendingWaits.has(token)) continue; + const key = s.bindingKey || makeBindingKey(s.workspace, s.cursorTitle || s.title); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push({ token, s }); + } + let removed = 0; + for (const [, arr] of groups) { + if (arr.length <= 1) continue; + arr.sort((a, b) => (b.s.lastActiveAt || 0) - (a.s.lastActiveAt || 0)); + for (let i = 1; i < arr.length; i++) { + const { token, s } = arr[i]; + if ((s.messages || []).length <= 2 && s.status === 'idle') { + sessions.delete(token); + removed++; + } + } + } + if (removed > 0) { + saveSessions(); + log('dedupe bindings', { removed }); + } + return removed; +} + +function loadSettings() { + try { + hubSettings = { ...DEFAULT_SETTINGS, ...JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) }; + } catch { + hubSettings = { ...DEFAULT_SETTINGS }; + } + if (Number(hubSettings.autoContinueDelayMs) === 400) { + hubSettings.autoContinueDelayMs = 10000; + saveSettings(); + } +} + +function saveSettings() { + try { fs.writeFileSync(SETTINGS_FILE, JSON.stringify(hubSettings, null, 2)); } catch (e) { + log('settings save failed', String(e.message || e)); + } +} + +loadSettings(); + +function loadSessions() { + try { + const raw = fs.readFileSync(SESSIONS_FILE, 'utf8'); + for (const [k, v] of Object.entries(JSON.parse(raw))) sessions.set(k, v); + log('sessions loaded', { count: sessions.size }); + } catch (e) { log('sessions load skip', String(e.message || e)); } +} + +let lastMongoSyncAt = 0; +const MONGO_SYNC_MIN_MS = 120000; + +function syncMongoAsync() { + try { + const now = nowMs(); + if (now - lastMongoSyncAt < MONGO_SYNC_MIN_MS) return; + lastMongoSyncAt = now; + const syncScript = path.join(__dirname, 'mongo_sync.py'); + if (!fs.existsSync(syncScript)) return; + const { spawn } = require('child_process'); + spawn('python3', [syncScript], { detached: true, stdio: 'ignore' }).unref(); + } catch {} +} + +/** 面板 /api/state 轮询勿做重 IO;维护任务降频 */ +let lastStateMaintenanceAt = 0; +const STATE_MAINTENANCE_MS = 60000; + +function maybeStateMaintenance() { + const now = nowMs(); + if (now - lastStateMaintenanceAt < STATE_MAINTENANCE_MS) return; + lastStateMaintenanceAt = now; + restoreSessionsFromBindings(); + pruneSessions(); +} + +function saveSessions() { + try { + const obj = {}; + for (const [k, v] of sessions.entries()) obj[k] = v; + fs.writeFileSync(SESSIONS_FILE, JSON.stringify(obj, null, 2)); + syncMongoAsync(); + } catch (e) { log('sessions save failed', String(e.message || e)); } +} + +loadSessions(); +loadBindingRegistry(); +loadPlanRegistry(); +rebuildBindingRegistryFromThreadFiles(); + +/** 从 sessions 回填 registry(迁移) */ +for (const [token, s] of sessions.entries()) { + if (s.status === 'completed') continue; + const key = s.bindingKey || (s.cursorTitle ? makeBindingKey(s.workspace, s.cursorTitle) : ''); + if (key && !bindingRegistry[key]) { + bindingRegistry[key] = { token, updatedAt: s.lastActiveAt || s.createdAt || Date.now() }; + } + const pl = String(s.planLabel || s.title || '').trim(); + const ct = s.cursorTitle || s.title || ''; + if (pl && ct && !getPlanByToken(token)) { + registerPlanBinding(s.workspace, ct, s.hostIp, pl, token); + } +} +saveBindingRegistry(); + +/** 清理 sessions.json 里残留的 waiting(旧架构遗留),仅以内存 pendingWaits 为准 */ +for (const [token, s] of sessions.entries()) { + if (s.status === 'waiting') s.status = 'idle'; +} + +const CONTINUE_MODES = new Set(['standard', 'turbo', 'optimize']); + +function normalizeContinueMode(mode) { + const m = String(mode || '').trim(); + return CONTINUE_MODES.has(m) ? m : 'standard'; +} + +function getSessionContinueMode(s) { + if (s && s.continueMode) return normalizeContinueMode(s.continueMode); + return normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode || 'standard'); +} + +function effectiveAutoContinue(s) { + if (s && typeof s.sessionAutoContinue === 'boolean') return s.sessionAutoContinue; + if (hubSettings.defaultSessionAutoContinue === false) return false; + return hubSettings.autoContinue !== false; +} + +function effectiveTimedReply(s) { + if (hubSettings.timedReplyEnabled === false) return false; + if (s && s.timedReplyEnabled === false) return false; + return true; +} + +function shouldAlwaysKeepAlive(s) { + if (hubSettings.alwaysKeepAlive === false) return false; + if (s && s.alwaysKeepAlive === false) return false; + return effectiveAutoContinue(s); +} + +function buildKeepAliveContinueText(s, review) { + const mode = getSessionContinueMode(s); + const goal = String((s && s.projectGoal) || (review && review.projectGoal) || '').slice(0, 150); + if (review && review.nextStep && !review.isIdleNext) { + return `【保活续跑】继续执行▶下一步:${review.nextStep.slice(0, 400)}。保持连接勿停。`; + } + if (mode === 'turbo') { + return String( + hubSettings.keepAliveTurboText + || hubSettings.turboContinueText + || '【保活续跑】保持连接,继续执行当前计划与学习清单,直推直到目标完成。', + ); + } + if (mode === 'optimize') { + return '【保活续跑】保持连接,继续优化流程:总结→检索→补文档→开发,按学习清单推进。'; + } + if (goal) { + return `【保活续跑】继续推进:${goal}。按复盘与▶下一步执行,保持连接勿停。`; + } + return String( + hubSettings.keepAliveText + || hubSettings.timedReplyText + || '【保活续跑】保持持久对话连接,继续执行与学习,勿停。', + ); +} + +function isTimedReplyAuto(s) { + if (s && s.sessionTimedIntervalMs != null) { + const sv = Number(s.sessionTimedIntervalMs); + if (Number.isFinite(sv) && sv <= 0) return true; + if (Number.isFinite(sv) && sv >= TIMED_REPLY_MIN_MS) return false; + } + const raw = s && s.timedReplyIntervalMs != null + ? Number(s.timedReplyIntervalMs) + : Number(hubSettings.timedReplyIntervalMs); + return !Number.isFinite(raw) || raw <= 0; +} + +function getTimedReplyIntervalMs(s) { + if (isTimedReplyAuto(s)) return 0; + const raw = s.sessionTimedIntervalMs != null + ? Number(s.sessionTimedIntervalMs) + : (s && s.timedReplyIntervalMs != null + ? Number(s.timedReplyIntervalMs) + : Number(hubSettings.timedReplyIntervalMs)); + if (Number.isFinite(raw) && raw >= TIMED_REPLY_MIN_MS) return Math.floor(raw); + return 0; +} + +/** 「自动」间隔 = 10 秒保活连接 */ +function getEffectiveTimedIntervalMs(s) { + const raw = getTimedReplyIntervalMs(s); + if (raw >= TIMED_REPLY_MIN_MS) return raw; + return CONNECTION_KEEPALIVE_MS; +} + +function markConnectionHeld(s) { + if (!s || s.status === 'completed' || s.connectionEnded) return; + s.connectionHeld = true; + s.lastKeepAliveAt = nowMs(); + if (s.status !== 'waiting') s.status = 'holding'; +} + +function clearConnectionHeld(s) { + if (!s) return; + s.connectionHeld = false; + if (s.status === 'holding') s.status = 'idle'; +} + +function isSessionConnectionHeld(s, pending) { + if (!s || s.status === 'completed' || s.connectionEnded) return false; + return !!(pending || s.connectionHeld); +} + +function pruneSteerFromMessages(s, texts) { + if (!s?.messages?.length || !texts?.length) return; + const set = new Set(texts.map((t) => String(t || '').trim()).filter(Boolean)); + s.messages = s.messages.filter((m) => { + if (!m.queued || m.role !== 'user') return true; + return !set.has(String(m.content || '').trim()); + }); +} + +function clearWaitTimers(wait) { + if (!wait) return; + if (wait.autoTimer) { + clearTimeout(wait.autoTimer); + wait.autoTimer = null; + } + if (wait.timedReplyTimer) { + clearTimeout(wait.timedReplyTimer); + wait.timedReplyTimer = null; + } + wait.timedReplyFireAt = null; +} + +function buildTimedReplyText(token, wait, s, review) { + const mode = getSessionContinueMode(s); + if (mode === 'turbo') { + return String( + hubSettings.timedReplyTurboText + || hubSettings.turboContinueText + || '【定时续跑】保持连接,继续执行当前计划,直推开发直到目标完成。', + ); + } + if (mode === 'optimize') { + return '【定时续跑】保持连接,继续优化流程:总结→检索→补文档→开发。'; + } + if (review.nextStep && !review.isIdleNext) { + return `【定时回复】继续执行▶下一步:${review.nextStep.slice(0, 300)}`; + } + return String(hubSettings.timedReplyText || '【定时回复】保持持久对话连接,若仍有任务请继续执行。'); +} + +function triggerTimedReply(token) { + const wait = pendingWaits.get(token); + if (!wait) return { ok: false, error: 'no pending wait' }; + focusPanelTarget(token); + const s = sessions.get(token); + if (!effectiveTimedReply(s)) return { ok: false, error: 'timed reply disabled' }; + const review = parseKaruoReview(wait.message || ''); + const text = buildTimedReplyText(token, wait, s, review); + if (wait.timedReplyTimer) clearTimeout(wait.timedReplyTimer); + wait.timedReplyTimer = null; + wait.timedReplyFireAt = null; + deliverReply(token, text, { auto: true, timed: true }); + log('timedReply fired', token, { text: text.slice(0, 80) }); + return { ok: true, token, text: text.slice(0, 120) }; +} + +function scheduleTimedReply(token) { + const s = sessions.get(token); + if (!effectiveTimedReply(s)) return; + const wait = pendingWaits.get(token); + if (!wait) return; + if (wait.timedReplyTimer) clearTimeout(wait.timedReplyTimer); + const interval = getEffectiveTimedIntervalMs(s); + if (interval < TIMED_REPLY_MIN_MS) return; + wait.timedReplyFireAt = nowMs() + interval; + wait.timedReplyTimer = setTimeout(() => { + if (!pendingWaits.has(token)) return; + triggerTimedReply(token); + }, interval); + log('timedReply scheduled', token, { intervalMs: interval, fireAt: wait.timedReplyFireAt }); +} + +/** 迁移:旧会话补默认单会话配置 */ +for (const [, s] of sessions.entries()) { + if (s.continueMode == null) { + s.continueMode = normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode); + } + if (typeof s.sessionAutoContinue !== 'boolean') { + s.sessionAutoContinue = hubSettings.defaultSessionAutoContinue !== false; + } + if (typeof s.timedReplyEnabled !== 'boolean') { + s.timedReplyEnabled = hubSettings.defaultTimedReplyEnabled !== false; + } + if (s.timedReplyIntervalMs == null) { + s.timedReplyIntervalMs = hubSettings.timedReplyIntervalMs != null + ? hubSettings.timedReplyIntervalMs + : CONNECTION_KEEPALIVE_MS; + } + if (s.sessionTimedIntervalMs == null && s.timedReplyIntervalMs == null) { + s.sessionTimedIntervalMs = 0; + } + if (typeof s.connectionHeld !== 'boolean') s.connectionHeld = false; + if (s.archived) continue; + if (typeof s.sessionAutoContinue !== 'boolean' || s.sessionAutoContinue === false) { + s.sessionAutoContinue = true; + } + if (s.timedReplyEnabled === false) s.timedReplyEnabled = true; + if (s.alwaysKeepAlive !== false) s.alwaysKeepAlive = true; + if (!s.lastKeepAliveAt) s.lastKeepAliveAt = s.lastActiveAt || s.createdAt || nowMs(); +} +saveSessions(); + +function archiveStaleSessions() { + const now = nowMs(); + const stale = []; + for (const [token, s] of sessions.entries()) { + if (s.archived || s.status === 'completed') continue; + if (pendingWaits.has(token)) continue; + const last = s.lastKeepAliveAt || s.lastActiveAt || s.createdAt || now; + if (now - last < STALE_KEEPALIVE_MS) continue; + stale.push({ + token, + title: s.title, + cursorTitle: s.cursorTitle, + planLabel: s.planLabel, + workspace: s.workspace, + lastKeepAliveAt: s.lastKeepAliveAt, + lastActiveAt: s.lastActiveAt, + msgCount: (s.messages || []).length, + messages: (s.messages || []).slice(-50), + }); + s.archived = true; + s.status = 'completed'; + s.sessionAutoContinue = false; + s.timedReplyEnabled = false; + s.connectionHeld = false; + s.archivedAt = now; + s.archiveReason = 'stale_keepalive_2h'; + } + if (!stale.length) return 0; + const day = new Date().toISOString().slice(0, 10); + const dir = path.join(ARCHIVE_DIR, day); + try { fs.mkdirSync(dir, { recursive: true }); } catch {} + const file = path.join(dir, `stale_${now}.json`); + try { + fs.writeFileSync(file, JSON.stringify({ archivedAt: now, reason: 'no_keepalive_2h', sessions: stale }, null, 2)); + } catch (e) { + log('archive write failed', String(e.message || e)); + } + saveSessions(); + log('archiveStaleSessions', { count: stale.length, file }); + return stale.length; +} + +function isSilentMessage(message, token) { + const s = token ? sessions.get(token) : null; + if (effectiveAutoContinue(s)) return true; + const msg = String(message || '').replace(/```text|```/g, '').trim(); + if (!msg) return true; + if (/自动续跑空转|续跑机制正常|机制已就绪|空转中|Hub.*自动|仍在.*续跑/.test(msg)) return true; + return false; +} + +function stripTransportFence(message) { + let msg = String(message || ''); + const fenced = msg.match(/^```text\n([\s\S]*)\n```\s*$/); + if (fenced) msg = fenced[1]; + return msg.replace(/```text|```/g, '').trim(); +} + +/** 从 Agent 回复解析卡若复盘 v5(🎯📌💡📝▶) */ +function parseKaruoReview(message) { + const raw = stripTransportFence(message); + const hasReview = /\[卡若复盘\]|🎯\s*目标/.test(raw); + let completionPct = null; + const goalSection = raw.match(/🎯[\s\S]*?(?=\n\*\*📌|\n\*\*💡|\n\*\*📝|\n\*\*▶|$)/); + const pctMatch = goalSection && goalSection[0].match(/([0-9]{1,3})\s*%/); + if (pctMatch) completionPct = Math.min(100, Math.max(0, parseInt(pctMatch[1], 10))); + let nextStep = ''; + const nextBlock = raw.match(/\*\*▶\s*下一步执行\*\*\s*\n([\s\S]*?)(?=\n\*\*[📌💡📝🎯▶]|$)/); + if (nextBlock) nextStep = nextBlock[1].trim(); + if (!nextStep) { + const alt = raw.match(/▶\s*下一步执行[^\n]*\n([\s\S]{1,400}?)(?=\n\n\*\*|$)/); + if (alt) nextStep = alt[1].trim(); + } + nextStep = nextStep.replace(/^[\d]+[.、]\s*/, '').trim(); + const isIdleNext = !nextStep || /^(无[。.]?|暂无|待命|等下一|等你的|等用户|无新任务|无待办|在\.|在$|\.\.\.|…)/.test(nextStep); + const needsZhuiwen = completionPct != null && completionPct >= 100; + let projectGoal = ''; + const goalLine = raw.match(/🎯[^\n]+/); + if (goalLine) { + projectGoal = goalLine[0] + .replace(/^\*\*🎯\s*目标·结果·达成率\*\*\s*/, '') + .replace(/^🎯\s*目标·结果·达成率\s*/, '') + .trim(); + } + return { hasReview, completionPct, nextStep, isIdleNext, needsZhuiwen, projectGoal, raw }; +} + +function isIdleAssistantWait(message) { + if (isSilentMessage(message)) return true; + const review = parseKaruoReview(message); + if (review.hasReview && review.isIdleNext && !review.needsZhuiwen) return true; + const msg = stripTransportFence(message); + if (msg.length < 50) return true; + if (/^(\[RENEWAL\]|继续|__TIMEOUT_RENEW__)/.test(msg)) return true; + return false; +} + +function initPreRun(session, nextStep) { + if (!nextStep) return; + const prev = session.preRun; + if (prev && prev.nextStep === nextStep && prev.stage && prev.stage !== 'done') return; + session.preRun = { + nextStep, + stage: 'zhuiwen', + zhuiwenSent: false, + searchSent: false, + }; +} + +function initOptimizeRun(session, nextStep) { + if (!nextStep) return; + const prev = session.optimizeRun; + if (prev && prev.nextStep === nextStep && prev.stage && prev.stage !== 'done') return; + session.optimizeRun = { nextStep, stage: 'summarize' }; + session.preRun = null; +} + +function advanceOptimizeRunStage(session) { + const opt = session.optimizeRun; + if (!opt) return; + const order = ['summarize', 'search', 'doc', 'execute', 'done']; + const i = order.indexOf(opt.stage); + if (i >= 0 && i < order.length - 1) opt.stage = order[i + 1]; +} + +/** 优化续跑:总结 → 检索 → 补文档 → 执行 */ +function resolveOptimizeContinueText(session, review) { + const opt = session.optimizeRun; + if (!opt?.nextStep || review.isIdleNext) return null; + const step = opt.nextStep.slice(0, 300); + const texts = { + summarize: + `【优化续跑①总结】回顾本会话聊天与已改代码,提炼/优化任务目标、缺口与风险,写入📌过程。`, + search: + `【优化续跑②检索】针对「${step}」:WebSearch 全网最佳实践 + GitHub 开源方案(各≥1次),对比选配套方案,摘要写📌。`, + doc: + `【优化续跑③补文档】把总结与检索结论补充进项目 开发文档/ 对应 MD(列出路径,不删旧内容)。`, + execute: + `【优化续跑④执行】文档已对齐,按文档继续执行▶下一步:${step}`, + }; + if (opt.stage === 'done') return null; + const text = texts[opt.stage]; + if (!text) return null; + session.phase = `opt_${opt.stage}`; + return text; +} + +/** 续跑三段式:追问 → 全网/GitHub 检索 → 执行 ▶(挂起后自动续跑前必经) */ +function resolvePreRunContinueText(session, review) { + const pre = session.preRun; + if (!pre || !pre.nextStep || review.isIdleNext) return null; + + const step = pre.nextStep.slice(0, 300); + const ZHUIWEN_SKILL = '04_卡火(火)/火眼_智能追问/智能追问/SKILL.md'; + + if (pre.stage === 'zhuiwen') { + if (pre.zhuiwenSent) { + log('preRun pause await 追问完成', pre.stage); + return null; + } + pre.zhuiwenSent = true; + pre.stage = 'zhuiwen_wait'; + session.phase = 'pre_zhuiwen'; + return ( + `【续跑①追问】挂起后续跑前,先读 \`${ZHUIWEN_SKILL}\`,对▶下一步「${step}」做 Human 3.0 结构化追问(目标/边界/验收,2~3 轮)。` + + `在本轮正文输出追问清单与结论,复盘后 wait。你或面板确认后只回复「追问完成」。` + ); + } + if (pre.stage === 'zhuiwen_wait') { + log('preRun pause zhuiwen_wait'); + return null; + } + if (pre.stage === 'search') { + if (pre.searchSent) { + log('preRun pause await 检索完成'); + return null; + } + pre.searchSent = true; + pre.stage = 'search_wait'; + session.phase = 'pre_search'; + return ( + `【续跑②检索】追问已完成。针对「${step}」:` + + `① WebSearch 全网查最佳实践/踩坑 ② GitHub 搜开源实现/Discussion/Issue(至少各 1 次有效检索)。` + + `摘要写入📌过程,结论影响执行方案。完成后只回复「检索完成」。` + ); + } + if (pre.stage === 'search_wait') { + log('preRun pause search_wait'); + return null; + } + if (pre.stage === 'execute') { + session.preRun = { ...pre, stage: 'done' }; + session.phase = 'running'; + return `【续跑③执行】追问与检索已完成。现在执行▶下一步:${step}`; + } + return null; +} + +function advancePreRunOnReply(session, reply) { + if (!session.preRun) return; + const r = String(reply || ''); + if (/追问完成|验收通过|追问结束|验收完毕/.test(r)) { + if (session.preRun.stage === 'zhuiwen_wait') { + session.preRun.stage = 'search'; + session.preRun.searchSent = false; + session.phase = 'pre_search'; + log('preRun advanced → search'); + } + } + if (/检索完成|搜索完成|检索完毕|搜完/.test(r)) { + if (session.preRun.stage === 'search_wait') { + session.preRun.stage = 'execute'; + session.phase = 'pre_execute'; + log('preRun advanced → execute'); + } + } +} + +function autoContinueMaxStreakLimit() { + const n = Number(hubSettings.autoContinueMaxStreak); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.floor(n); +} + +function deleteSession(token, force = false, opts = {}) { + if (!sessions.has(token)) return { ok: false, error: 'not found' }; + if (isVerticallyLockedToken(token) && !opts.unbind) { + return { + ok: false, + error: 'BINDING_PROTECTED', + hint: '该 ct_ 为垂直绑定会话,禁止面板/脚本误删;仅可删会话占位,绑定保留。如需解除请传 unbind:true。', + }; + } + if (pendingWaits.has(token)) { + if (!force) return { ok: false, error: 'waiting', hint: '会话挂起中,可强制删除' }; + const wait = pendingWaits.get(token); + if (wait) clearWaitTimers(wait); + for (const r of wait.pollers) { + try { json(r, 200, { status: 'gone', token }); } catch {} + } + pendingWaits.delete(token); + } + sessions.delete(token); + saveSessions(); + log('session deleted', token, { force }); + return { ok: true, token }; +} + +function pruneSessions() { + const protectedTokens = new Set(pendingWaits.keys()); + for (const hit of Object.values(bindingRegistry)) { + if (hit && hit.token) protectedTokens.add(hit.token); + } + const max = Math.max(5, Number(hubSettings.maxSessions) || 30); + const maxAgeMs = Math.max(1, Number(hubSettings.maxIdleAgeDays) || 7) * 86400000; + const now = nowMs(); + const entries = [...sessions.entries()].filter(([t]) => !protectedTokens.has(t)); + entries.sort((a, b) => (b[1].lastActiveAt || 0) - (a[1].lastActiveAt || 0)); + + let pruned = 0; + for (let i = max; i < entries.length; i++) { + sessions.delete(entries[i][0]); + pruned++; + } + for (const [token, s] of [...sessions.entries()]) { + if (protectedTokens.has(token)) continue; + const age = now - (s.lastActiveAt || s.createdAt || now); + if (age > maxAgeMs && (s.status === 'idle' || s.status === 'completed')) { + sessions.delete(token); + pruned++; + } + } + if (pruned > 0) { + saveSessions(); + log('pruned sessions', { count: pruned, remain: sessions.size }); + } + return pruned; +} + +/** 删除隔离测试/空 holding/无消息 orphan;保留 registry+plan 绑定及有实质消息的会话 */ +function cleanupUnusedPlans(opts = {}) { + const keepExact = new Set((opts.keepTokens || []).map((t) => normalizeExactToken(t)).filter(Boolean)); + for (const hit of Object.values(bindingRegistry)) { + if (hit && hit.token) keepExact.add(normalizeExactToken(hit.token)); + } + for (const [k, hit] of Object.entries(planRegistry)) { + if (k.startsWith('ct_') && hit && hit.planBindingKey) continue; + if (hit && hit.token) keepExact.add(normalizeExactToken(hit.token)); + } + if (activeWaitToken) keepExact.add(normalizeExactToken(activeWaitToken)); + for (const t of pendingWaits.keys()) keepExact.add(normalizeExactToken(t)); + + const deletedTokens = []; + for (const [token, s] of [...sessions.entries()]) { + const tok = normalizeExactToken(token); + if (!tok) { sessions.delete(token); deletedTokens.push(token); continue; } + if (keepExact.has(tok)) continue; + const title = String(s.cursorTitle || s.title || ''); + const msgs = (s.messages || []).length; + const isTest = /隔离测试|isolation.?test/i.test(title); + const isEmptyHold = (s.status === 'holding' || s.status === 'idle') && msgs === 0; + const isCompleted = s.status === 'completed'; + const isOrphan = msgs === 0 && !s.bindingKey && !s.planBindingKey; + if (isTest || isCompleted || isEmptyHold || isOrphan) { + if (pendingWaits.has(token)) { + const wait = pendingWaits.get(token); + if (wait.autoTimer) clearTimeout(wait.autoTimer); + pendingWaits.delete(token); + } + if (activeWaitToken === token) clearActiveWaitToken(token); + sessions.delete(token); + deletedTokens.push(token); + } else if (msgs >= 3) { + keepExact.add(tok); + } + } + + for (const token of deletedTokens) { + for (const [key, hit] of Object.entries(bindingRegistry)) { + if (hit && hit.token === token) delete bindingRegistry[key]; + } + if (planRegistry[token]) delete planRegistry[token]; + for (const [pk, hit] of Object.entries(planRegistry)) { + if (hit && hit.token === token) delete planRegistry[pk]; + } + try { + const tb = path.join(THREAD_BINDINGS_DIR, `${token}.json`); + if (fs.existsSync(tb)) fs.unlinkSync(tb); + } catch {} + } + + saveSessions(); + saveBindingRegistry(); + savePlanRegistry(); + log('cleanupUnusedPlans', { deleted: deletedTokens.length, remain: sessions.size }); + return { deleted: deletedTokens.length, deletedTokens, remain: sessions.size }; +} + +restoreSessionsFromBindings(); +pruneSessions(); +dedupeBindingSessions(); +saveSessions(); + +function newToken() { return 'ct_' + crypto.randomBytes(4).toString('hex'); } +function nowMs() { return Date.now(); } + +function readBody(req, max = 1024 * 1024) { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (c) => { + body += c; + if (body.length > max) { req.destroy(); reject(new Error('body too large')); } + }); + req.on('end', () => resolve(body)); + req.on('error', reject); + }); +} + +function json(res, code, obj) { + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj)); +} + +function buildStateList() { + const list = []; + for (const [token, s] of sessions.entries()) { + const pending = pendingWaits.get(token); + const hideWaiting = effectiveAutoContinue(s); + const needsUser = pending && !pending.silent && !hideWaiting; + const connectionHeld = isSessionConnectionHeld(s, pending); + list.push({ + token, + title: s.title || '(未命名)', + displayTitle: s.displayTitle || '', + status: needsUser ? 'waiting' : (pending || connectionHeld ? 'holding' : (s.status === 'completed' ? 'completed' : 'idle')), + workspace: s.workspace, + createdAt: s.createdAt, + lastActiveAt: s.lastActiveAt, + msgCount: (s.messages || []).length, + pendingMessage: pending ? pending.message : null, + pendingPrompt: pending ? pending.prompt : null, + pendingTitle: pending ? pending.title : null, + pendingSince: pending ? pending.createdAt : null, + silent: pending ? !!pending.silent : false, + autoRunning: !!(pending && hideWaiting), + hasPending: !!pending, + agentWaitActive: !!(pending && !pending.silent), + waitingForUser: !!needsUser, + connectionHeld: !!connectionHeld, + isHung: !!(pending || connectionHeld), + lastKeepAliveAt: s.lastKeepAliveAt || null, + autoStreak: s.autoStreak || 0, + cursorTitle: s.cursorTitle || s.title || '', + bindingKey: s.bindingKey || makeBindingKey(s.workspace, s.cursorTitle || s.title, s.hostIp), + hostIp: s.hostIp || '', + hub: s.hub || hubEndpointLabel(), + bindingCheckCount: s.bindingCheckCount || 0, + isActiveWait: token === activeWaitToken, + projectGoal: s.projectGoal || '', + completionPct: typeof s.completionPct === 'number' ? s.completionPct : null, + lastReview: s.lastReview || null, + phase: s.phase || 'running', + planLabel: s.planLabel || '', + preRun: s.preRun || null, + optimizeRun: s.optimizeRun || null, + continueMode: getSessionContinueMode(s), + sessionAutoContinue: effectiveAutoContinue(s), + continueModeLabel: ({ standard: '复盘', turbo: '直通', optimize: '优化' })[getSessionContinueMode(s)], + timedReplyEnabled: effectiveTimedReply(s), + sessionTimedReply: effectiveTimedReply(s), + timedReplyAuto: isTimedReplyAuto(s), + timedReplyIntervalMs: getTimedReplyIntervalMs(s), + sessionTimedIntervalMs: isTimedReplyAuto(s) ? 0 : getTimedReplyIntervalMs(s), + effectiveTimedIntervalMs: getEffectiveTimedIntervalMs(s), + timedReplyFireAt: pending && pending.timedReplyFireAt ? pending.timedReplyFireAt : null, + registryToken: registryTokenForSession(token, s), + bindingOk: bindingOkForSession(token, s), + restoredFromBinding: !!s.restoredFromBinding, + steerQueueCount: Array.isArray(s.steerQueue) ? s.steerQueue.length : 0, + steerQueuePreview: (s.steerQueue || []).map((item, index) => ({ + index, + text: String(item.text || '').slice(0, 240), + at: item.at || null, + })), + recentMessages: (s.messages || []).slice(-8).map((m) => ({ + role: m.role, + preview: String(m.content || '').replace(/```text|```/g, '').slice(0, 400), + at: m.at, + auto: !!m.auto, + timed: !!m.timed, + })), + }); + } + list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); + return list; +} + +function queueSteerReply(token, reply) { + const s = sessions.get(token); + if (!s) return { ok: false, error: 'unknown token' }; + if (!Array.isArray(s.steerQueue)) s.steerQueue = []; + const text = String(reply || '').trim(); + if (!text) return { ok: false, error: 'empty reply' }; + s.steerQueue.push({ text, at: nowMs() }); + (s.messages = s.messages || []).push({ role: 'user', content: text, at: nowMs(), queued: true }); + s.lastActiveAt = nowMs(); + saveSessions(); + log('steer queued', token, { queueLen: s.steerQueue.length }); + return { ok: true, queued: true, queueLen: s.steerQueue.length }; +} + +function takeSteerReply(token) { + const s = sessions.get(token); + if (!s?.steerQueue?.length) return null; + const items = s.steerQueue.splice(0); + saveSessions(); + const combined = items.map((i) => i.text).join('\n\n'); + log('steer dequeue', token, { count: items.length, len: combined.length }); + return combined || null; +} + +function deliverReply(token, reply, meta = {}) { + const wait = pendingWaits.get(token); + if (!wait) return false; + clearWaitTimers(wait); + pendingWaits.delete(token); + const s = sessions.get(token); + if (s) { + s.lastActiveAt = nowMs(); + if (/结束持久对话/.test(String(reply || ''))) { + clearConnectionHeld(s); + s.connectionEnded = true; + s.sessionAutoContinue = false; + s.status = 'idle'; + } else { + // 回复送达后仍保持计划级连接态;下一次 Agent wait 会继续接收排队消息。 + markConnectionHeld(s); + } + (s.messages = s.messages || []).push({ + role: 'user', + content: reply, + at: nowMs(), + auto: !!meta.auto, + timed: !!meta.timed, + }); + advancePreRunOnReply(s, reply); + if (/追问完成|验收通过|追问结束|验收完毕/.test(reply) && !s.preRun) { + s.phase = 'zhuiwen_done'; + } + if (meta.auto || meta.timed) { + s.lastKeepAliveAt = nowMs(); + } + if (meta.auto) { + s.autoStreak = (s.autoStreak || 0) + 1; + if (getSessionContinueMode(s) === 'optimize' && s.optimizeRun && s.optimizeRun.stage !== 'done') { + advanceOptimizeRunStage(s); + } + } else { + s.autoStreak = 0; + } + saveSessions(); + } + for (const res of wait.pollers) { + try { json(res, 200, { status: 'replied', reply, token }); } catch {} + } + wait.pollers.clear(); + if (typeof wait.resolve === 'function') wait.resolve(reply); + clearActiveWaitToken(token); + log('reply delivered', token, `len=${reply.length}`, meta.timed ? 'timed' : (meta.auto ? 'auto' : 'manual')); + return true; +} + +function isTurboMode(s) { + return getSessionContinueMode(s) === 'turbo'; +} + +function isOptimizeMode(s) { + return getSessionContinueMode(s) === 'optimize'; +} + +/** 构建自动/手动「继续」文案;返回 null 表示本轮不续跑 */ +function buildAutoContinueText(token, wait, s, review) { + const sGoal = s && s.projectGoal ? String(s.projectGoal) : review.projectGoal || ''; + const sPct = review.completionPct != null + ? review.completionPct + : (s && typeof s.completionPct === 'number' ? s.completionPct : null); + let text = String(hubSettings.autoContinueText || '继续'); + + if (isOptimizeMode(s)) { + if (review.isIdleNext && !review.needsZhuiwen && sPct != null && sPct >= 100) { + log('optimizeContinue stop complete', token, { sPct }); + return null; + } + if (!s.optimizeRun) { + const seed = (review.nextStep && !review.isIdleNext) + ? review.nextStep + : (sGoal || '继续推进当前任务'); + initOptimizeRun(s, seed); + } + const optText = resolveOptimizeContinueText(s, review); + if (optText) { + saveSessions(); + return optText; + } + if (review.nextStep && !review.isIdleNext) { + return `【优化续跑·执行】继续▶:${review.nextStep.slice(0, 400)}。按已补文档直推。`; + } + if (sGoal) { + const pctPart = sPct != null ? `(约 ${sPct}%)` : ''; + return `【优化续跑】继续推进:${sGoal.slice(0, 150)}${pctPart}。`; + } + return null; + } + + if (isTurboMode(s)) { + if (review.isIdleNext && !review.needsZhuiwen && sPct != null && sPct >= 100) { + log('turboContinue stop complete', token, { sPct }); + return null; + } + if (review.nextStep && !review.isIdleNext) { + return ( + `【直通续跑】继续执行▶下一步:${review.nextStep.slice(0, 400)}。` + + '直推开发不要停,补缺失步骤即可,跳过追问/检索等待。' + ); + } + if (sGoal) { + const pctPart = sPct != null ? `(当前约 ${sPct}%)` : ''; + if (sPct != null && sPct >= 100 && review.isIdleNext) return null; + return ( + `【直通续跑】继续推进:${sGoal.slice(0, 150)}${pctPart}。` + + '一直开发直到目标完成,勿停勿等复盘确认。' + ); + } + if (!review.hasReview) { + return String(hubSettings.turboContinueText || '【直通续跑】继续执行当前计划,直推开发直到完成。'); + } + return text; + } + + if (!review.hasReview) { + log('autoContinue skipped no karuo review', token); + return null; + } + if (review.isIdleNext && !review.needsZhuiwen) { + log('autoContinue skipped idle next step', token, { nextStep: review.nextStep }); + return null; + } + + const preText = s ? resolvePreRunContinueText(s, review) : null; + if (preText) { + saveSessions(); + return preText; + } + if (s && s.preRun && !review.isIdleNext) { + log('autoContinue paused preRun pipeline', token, { stage: s.preRun.stage }); + return null; + } + if (review.needsZhuiwen && s && s.phase !== 'zhuiwen_done') { + if (!review.isIdleNext) initPreRun(s, review.nextStep); + const retry = s ? resolvePreRunContinueText(s, review) : null; + if (retry) { + saveSessions(); + return retry; + } + return null; + } + if (review.nextStep && !review.isIdleNext) { + if (s) initPreRun(s, review.nextStep); + const retry = s ? resolvePreRunContinueText(s, review) : null; + if (retry) { + saveSessions(); + return retry; + } + return null; + } + if (sGoal) { + const pctPart = sPct != null ? `(当前完成度约 ${sPct}%)` : ''; + if (sPct != null && sPct >= 100 && s && s.phase === 'zhuiwen_done') { + log('autoContinue skipped after zhuiwen', token); + return null; + } + return `继续推进:${sGoal.slice(0, 120)}${pctPart}。按▶下一步执行,勿重复已完成步骤。`; + } + return text; +} + +function focusPanelTarget(token) { + if (!pendingWaits.has(token)) return false; + if (activeWaitToken !== token) { + log('panel focus activeWaitToken', { from: activeWaitToken, to: token }); + setActiveWaitToken(token); + } + return true; +} + +function triggerContinue(token, meta = {}) { + const wait = pendingWaits.get(token); + if (!wait) { + return { ok: false, error: 'no pending wait', hint: '该 ct_ 未挂起,请在对应 Cursor 标签等 Agent wait 后再发' }; + } + focusPanelTarget(token); + const s = sessions.get(token); + const review = parseKaruoReview(wait.message || ''); + let text = buildAutoContinueText(token, wait, s, review); + if (!text && shouldAlwaysKeepAlive(s)) { + text = buildKeepAliveContinueText(s, review); + } + if (!text) { + const mode = getSessionContinueMode(s); + text = mode === 'turbo' + ? String(hubSettings.turboContinueText || '【直通续跑】继续执行当前计划,直推开发直到完成。') + : mode === 'optimize' + ? '【优化续跑】继续执行当前计划:总结→检索→补文档→开发。' + : String(hubSettings.autoContinueText || '继续'); + } + if ((isTurboMode(s) || isOptimizeMode(s)) && s && s.preRun) { + s.preRun = null; + s.phase = 'running'; + saveSessions(); + } + if (wait.autoTimer) clearTimeout(wait.autoTimer); + deliverReply(token, text, { auto: !!meta.auto, manualContinue: !!meta.manualContinue }); + return { ok: true, token, text: text.slice(0, 120) }; +} + +function scheduleAutoContinue(token) { + const s0 = sessions.get(token); + if (!effectiveAutoContinue(s0)) return; + const wait = pendingWaits.get(token); + if (!wait) return; + const s = sessions.get(token); + const maxStreak = autoContinueMaxStreakLimit(); + if (maxStreak > 0 && s && (s.autoStreak || 0) >= maxStreak) { + log('autoContinue paused', token, { streak: s.autoStreak, maxStreak }); + return; + } + if (wait.autoTimer) clearTimeout(wait.autoTimer); + const delay = Math.max(1000, Number(hubSettings.autoContinueDelayMs) || 10000); + const review = parseKaruoReview(wait.message || ''); + let text = buildAutoContinueText(token, wait, s, review); + if (!text && shouldAlwaysKeepAlive(s)) { + text = buildKeepAliveContinueText(s, review); + log('autoContinue keepalive fallback', token, { mode: getSessionContinueMode(s) }); + } + if (!text) return; + wait.autoTimer = setTimeout(() => { + if (!pendingWaits.has(token)) return; + triggerContinue(token, { auto: true }); + }, delay); +} + +function readPanelVersion() { + try { + const m = fs.readFileSync(PANEL_HTML, 'utf8').match(/PANEL_VERSION\s*=\s*['"]([^'"]+)['"]/); + if (m) return m[1]; + } catch {} + return 'unknown'; +} + +const server = http.createServer(async (req, res) => { + try { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); } + + const u = new URL(req.url, `http://${req.headers.host}`); + + if (u.pathname === '/api/health') { + return json(res, 200, { + ok: true, + pid: process.pid, + waiting: pendingWaits.size, + activeWaitToken, + panelVersion: readPanelVersion(), + }); + } + + if (u.pathname === '/' || u.pathname === '/panel') { + let html = fs.readFileSync(PANEL_HTML, 'utf8'); + const boot = JSON.stringify({ + sessions: buildStateList(), + settings: hubSettings, + panelVersion: readPanelVersion(), + activeWaitToken, + }).replace(/', `\n`); + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store, no-cache, must-revalidate', + 'Pragma': 'no-cache', + }); + return res.end(html); + } + + if (u.pathname === '/api/state' && req.method === 'GET') { + maybeStateMaintenance(); + for (const [, s] of sessions.entries()) markConnectionHeld(s); + const list = buildStateList(); + const primary = (activeWaitToken && pendingWaits.has(activeWaitToken)) + ? activeWaitToken + : (list.find((s) => s.hasPending)?.token + || list.find((s) => s.status === 'waiting')?.token + || null); + return json(res, 200, { + sessions: list, + primaryWaitingToken: primary, + activeWaitToken, + hubPid: process.pid, + settings: hubSettings, + sessionCount: list.length, + }); + } + + if (u.pathname === '/api/settings' && req.method === 'GET') { + return json(res, 200, { ok: true, settings: hubSettings }); + } + + if (u.pathname === '/api/archive' && req.method === 'GET') { + const files = []; + try { + if (fs.existsSync(ARCHIVE_DIR)) { + for (const day of fs.readdirSync(ARCHIVE_DIR)) { + const dayDir = path.join(ARCHIVE_DIR, day); + if (!fs.statSync(dayDir).isDirectory()) continue; + for (const f of fs.readdirSync(dayDir)) { + if (!f.endsWith('.json')) continue; + const fp = path.join(dayDir, f); + let meta = { file: fp, day }; + try { + const j = JSON.parse(fs.readFileSync(fp, 'utf8')); + meta = { ...meta, archivedAt: j.archivedAt, reason: j.reason, count: (j.sessions || []).length }; + } catch {} + files.push(meta); + } + } + } + } catch (e) { + log('archive list err', String(e.message || e)); + } + files.sort((a, b) => (b.archivedAt || 0) - (a.archivedAt || 0)); + return json(res, 200, { ok: true, archiveDir: ARCHIVE_DIR, staleMs: STALE_KEEPALIVE_MS, files }); + } + + if (u.pathname === '/api/settings' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + hubSettings = { + ...hubSettings, + ...(typeof body.autoContinue === 'boolean' ? { autoContinue: body.autoContinue } : {}), + ...(CONTINUE_MODES.has(body.autoContinueMode) + ? { autoContinueMode: body.autoContinueMode, defaultContinueMode: body.autoContinueMode } : {}), + ...(typeof body.defaultSessionAutoContinue === 'boolean' + ? { defaultSessionAutoContinue: body.defaultSessionAutoContinue } : {}), + ...(body.autoContinueDelayMs != null ? { autoContinueDelayMs: Number(body.autoContinueDelayMs) } : {}), + ...(body.autoContinueText != null ? { autoContinueText: String(body.autoContinueText) } : {}), + ...(body.turboContinueText != null ? { turboContinueText: String(body.turboContinueText) } : {}), + ...(body.autoContinueMaxStreak != null ? { autoContinueMaxStreak: Number(body.autoContinueMaxStreak) } : {}), + ...(typeof body.timedReplyEnabled === 'boolean' ? { timedReplyEnabled: body.timedReplyEnabled } : {}), + ...(typeof body.defaultTimedReplyEnabled === 'boolean' + ? { defaultTimedReplyEnabled: body.defaultTimedReplyEnabled } : {}), + ...(body.timedReplyIntervalMs != null ? { timedReplyIntervalMs: Number(body.timedReplyIntervalMs) } : {}), + ...(body.timedReplyText != null ? { timedReplyText: String(body.timedReplyText) } : {}), + ...(body.timedReplyTurboText != null ? { timedReplyTurboText: String(body.timedReplyTurboText) } : {}), + }; + saveSettings(); + log('settings updated', hubSettings); + return json(res, 200, { ok: true, settings: hubSettings }); + } + + if (u.pathname === '/api/session/config' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const { token } = body; + if (!token || !sessions.has(token)) { + return json(res, 404, { ok: false, error: 'not found' }); + } + const s = sessions.get(token); + if (typeof body.sessionAutoContinue === 'boolean') { + s.sessionAutoContinue = body.sessionAutoContinue; + } + if (body.continueMode && CONTINUE_MODES.has(body.continueMode)) { + s.continueMode = body.continueMode; + if (body.continueMode !== 'standard') { + s.preRun = null; + } + if (body.continueMode !== 'optimize') { + s.optimizeRun = null; + } + } + if (typeof body.timedReplyEnabled === 'boolean') { + s.timedReplyEnabled = body.timedReplyEnabled; + } + if (typeof body.sessionTimedReply === 'boolean') { + s.timedReplyEnabled = body.sessionTimedReply; + s.sessionTimedReply = body.sessionTimedReply; + } + const applyTimedIntervalMs = (ms) => { + if (!Number.isFinite(ms)) return; + if (ms <= 0) { + s.timedReplyIntervalMs = 0; + s.sessionTimedIntervalMs = 0; + } else if (ms >= TIMED_REPLY_MIN_MS) { + const v = Math.floor(ms); + s.timedReplyIntervalMs = v; + s.sessionTimedIntervalMs = v; + } + }; + if (body.timedReplyIntervalMs != null) { + applyTimedIntervalMs(Number(body.timedReplyIntervalMs)); + } + if (body.sessionTimedIntervalMs != null) { + applyTimedIntervalMs(Number(body.sessionTimedIntervalMs)); + } + if (body.displayTitle != null) { + const t = String(body.displayTitle).trim().slice(0, 120); + if (t) s.displayTitle = t; + else delete s.displayTitle; + } + s.lastActiveAt = nowMs(); + saveSessions(); + if (pendingWaits.has(token)) { + scheduleTimedReply(token); + if (effectiveAutoContinue(s)) scheduleAutoContinue(token); + } + log('session config', token, { + sessionAutoContinue: s.sessionAutoContinue, + continueMode: getSessionContinueMode(s), + timedReplyEnabled: effectiveTimedReply(s), + timedReplyIntervalMs: getTimedReplyIntervalMs(s), + }); + return json(res, 200, { + ok: true, + token, + sessionAutoContinue: effectiveAutoContinue(s), + continueMode: getSessionContinueMode(s), + continueModeLabel: ({ standard: '复盘', turbo: '直通', optimize: '优化' })[getSessionContinueMode(s)], + timedReplyEnabled: effectiveTimedReply(s), + timedReplyAuto: isTimedReplyAuto(s), + timedReplyIntervalMs: getTimedReplyIntervalMs(s), + timedReplyFireAt: pendingWaits.get(token)?.timedReplyFireAt || null, + autoContinueDelayMs: hubSettings.autoContinueDelayMs, + }); + } + + if (u.pathname === '/api/reply' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const { token, reply, cursorTitle: replyTitle } = body; + if (!token || typeof reply !== 'string') { + return json(res, 400, { ok: false, error: 'bad request' }); + } + const s = sessions.get(token); + if (!s) { + return json(res, 404, { ok: false, error: 'unknown token' }); + } + if (replyTitle) { + const v = validateTokenBinding(token, s.workspace, replyTitle, s.hostIp || body.hostIp); + if (!v.ok) { + return json(res, 403, { ok: false, ...v }); + } + } + if (!s) { + return json(res, 404, { ok: false, error: 'unknown token' }); + } + if (!pendingWaits.has(token)) { + const q = queueSteerReply(token, reply); + if (!q.ok) return json(res, 400, q); + return json(res, 200, q); + } + focusPanelTarget(token); + deliverReply(token, reply); + return json(res, 200, { ok: true, token }); + } + + if (u.pathname === '/api/continue' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const { token } = body; + if (!token) return json(res, 400, { ok: false, error: 'bad request' }); + const result = triggerContinue(token, { manualContinue: true }); + if (!result.ok) { + const code = result.error === 'NOT_ACTIVE_WAIT_TOKEN' ? 403 : 404; + return json(res, code, { ok: false, ...result }); + } + return json(res, 200, result); + } + + if (u.pathname === '/api/wait/register' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const { token, message, prompt, title, workspace, pid, hostIp: bodyHostIp, hub: bodyHub } = body; + if (!token || !sessions.has(token)) { + return json(res, 404, { ok: false, error: 'unknown token' }); + } + const s = sessions.get(token); + const cursorTitle = title || s.cursorTitle || ''; + const hostIp = normHostIp(bodyHostIp || s.hostIp); + const hub = bodyHub || s.hub || hubEndpointLabel(); + const v = validateTokenBinding(token, workspace || s.workspace, cursorTitle, hostIp, { + allowTitleRename: true, + }); + if (!v.ok) { + log('wait register rejected', v); + return json(res, 403, { ok: false, ...v }); + } + if (cursorTitle) { + s.cursorTitle = cursorTitle; + s.bindingKey = makeBindingKey(workspace || s.workspace, cursorTitle, hostIp); + if (!s.title || s.title === '新对话') s.title = cursorTitle; + } + if (workspace) s.workspace = workspace; + s.hostIp = hostIp; + s.hub = hub; + s.lastActiveAt = nowMs(); + s.status = 'waiting'; + markConnectionHeld(s); + (s.messages = s.messages || []).push({ role: 'assistant', content: String(message || ''), at: nowMs() }); + const review = parseKaruoReview(message); + s.lastReview = { + hasReview: review.hasReview, + completionPct: review.completionPct, + nextStep: review.nextStep, + isIdleNext: review.isIdleNext, + needsZhuiwen: review.needsZhuiwen, + at: nowMs(), + }; + if (review.projectGoal) s.projectGoal = review.projectGoal.slice(0, 200); + if (review.completionPct != null) s.completionPct = review.completionPct; + if (!review.isIdleNext && review.nextStep) { + if (getSessionContinueMode(s) === 'standard') initPreRun(s, review.nextStep); + if (isOptimizeMode(s)) initOptimizeRun(s, review.nextStep); + } + if (/追问完成|验收通过|追问结束/.test(String(message || ''))) { + advancePreRunOnReply(s, String(message || '')); + } + if (effectiveAutoContinue(s) && !isIdleAssistantWait(message)) { + s.autoStreak = 0; + } + saveSessions(); + registerBinding(workspace || s.workspace, cursorTitle, token, { hostIp, hub }); + + setActiveWaitToken(token); + + const old = pendingWaits.get(token); + if (old) { + clearWaitTimers(old); + for (const r of old.pollers) { + try { json(r, 200, { status: 'renewal', token }); } catch {} + } + } + pendingWaits.set(token, { + message: String(message || ''), + prompt: prompt || '', + title: title || '', + createdAt: nowMs(), + workspace: workspace || s.workspace, + pid: pid || null, + pollers: new Set(), + resolve: null, + autoTimer: null, + timedReplyTimer: null, + timedReplyFireAt: null, + silent: isSilentMessage(message, token), + }); + log('wait register', token, { + pid, + autoContinue: effectiveAutoContinue(s), + timedReply: effectiveTimedReply(s), + timedIntervalMs: getTimedReplyIntervalMs(s), + mode: getSessionContinueMode(s), + silent: isSilentMessage(message, token), + }); + if (effectiveAutoContinue(s)) scheduleAutoContinue(token); + if (effectiveTimedReply(s)) scheduleTimedReply(token); + return json(res, 200, { + ok: true, + token, + autoContinue: effectiveAutoContinue(s), + continueMode: getSessionContinueMode(s), + hostIp, + hub, + bindingKey: s.bindingKey, + bindingCheckCount: s.bindingCheckCount || 0, + bindingUnchanged: true, + }); + } + + if (u.pathname === '/api/wait/poll' && req.method === 'GET') { + const token = u.searchParams.get('token'); + if (!token) return json(res, 400, { error: 'missing token' }); + + const wait = pendingWaits.get(token); + if (!wait) { + return json(res, 404, { status: 'gone', error: 'no pending wait for token' }); + } + + const steered = takeSteerReply(token); + if (steered) { + deliverReply(token, steered, { steer: true }); + return json(res, 200, { status: 'replied', reply: steered, token }); + } + + let done = false; + const timer = setTimeout(() => { + if (done) return; + done = true; + wait.pollers.delete(res); + json(res, 200, { status: 'renewal', token }); + }, POLL_TIMEOUT_MS); + + wait.pollers.add(res); + res.on('close', () => { + if (!done) { done = true; clearTimeout(timer); wait.pollers.delete(res); } + }); + return; + } + + if (u.pathname === '/api/init' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const workspace = body.workspace || ''; + const cursorTitle = body.cursorTitle || body.title || ''; + const newPlan = !!(body.newPlan || body.force); + const planLabel = String(body.planLabel || '').trim(); + const hostIp = normHostIp(body.hostIp); + const hub = body.hub || hubEndpointLabel(); + let existing = findBoundSession(workspace, cursorTitle, hostIp, planLabel || null); + + if (newPlan && existing) { + // 新计划只创建新的完整 token,不归档旧计划;旧 token 继续保持 holding/waiting。 + log('init keep old for newPlan', existing, { planLabel }); + existing = null; + } + + if (existing && !newPlan) { + const s = sessions.get(existing); + s.lastActiveAt = nowMs(); + if (cursorTitle) { + s.cursorTitle = cursorTitle; + s.bindingKey = makeBindingKey(workspace, cursorTitle, hostIp); + } + s.hostIp = hostIp; + s.hub = hub; + saveSessions(); + registerBinding(workspace, cursorTitle, existing, { hostIp, hub, planLabel: planLabel || s.planLabel || s.title }); + log('init reuse', existing, s.bindingKey); + return json(res, 200, { + ok: true, + token: existing, + reused: true, + bindingKey: s.bindingKey, + hostIp, + hub, + bindingMode: 'reuse', + hint: '同标签+同IP已绑定此 token;换新计划请 newPlan:true', + }); + } + const activeCount = Array.from(sessions.values()).filter((s) => s.status !== 'completed').length; + if (activeCount > 0 && !body.force && !cursorTitle) { + return json(res, 409, { + ok: false, + error: 'SESSION_LIST_NOT_EMPTY', + count: activeCount, + hint: '传 cursorTitle 复用/新建绑定;或用户明确「新建持久对话」时 force=true', + }); + } + const token = newToken(); + const sessionTitle = planLabel || cursorTitle || body.title || '新对话'; + sessions.set(token, { + token, + title: sessionTitle, + planLabel: planLabel || '', + cursorTitle: cursorTitle || '', + bindingKey: cursorTitle ? makeBindingKey(workspace, cursorTitle, hostIp) : '', + hostIp, + hub, + workspace, + status: 'holding', + connectionHeld: true, + phase: 'running', + preRun: null, + optimizeRun: null, + sessionAutoContinue: hubSettings.defaultSessionAutoContinue !== false, + continueMode: normalizeContinueMode(hubSettings.defaultContinueMode || hubSettings.autoContinueMode), + timedReplyEnabled: hubSettings.defaultTimedReplyEnabled !== false, + timedReplyIntervalMs: hubSettings.timedReplyIntervalMs != null + ? hubSettings.timedReplyIntervalMs + : 0, + bindingCheckCount: 0, + bindingLockedAt: nowMs(), + createdAt: nowMs(), + lastActiveAt: nowMs(), + messages: [], + }); + saveSessions(); + dedupeBindingSessions(); + registerBinding(workspace, cursorTitle, token, { hostIp, hub, planLabel: planLabel || sessionTitle }); + log('init', token, { bindingKey: sessions.get(token).bindingKey, newPlan, planLabel, hostIp }); + return json(res, 200, { + ok: true, + token, + newPlan, + planLabel, + bindingKey: sessions.get(token).bindingKey, + hostIp, + hub, + bindingMode: 'auto_new', + }); + } + + if (u.pathname === '/api/session/restore' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const { token } = body; + if (!token) return json(res, 400, { ok: false, error: 'missing token' }); + rebuildBindingRegistryFromThreadFiles(); + let found = false; + for (const [key, hit] of Object.entries(bindingRegistry)) { + if (hit && hit.token === token) { + if (!sessions.has(token)) { + bindingRegistry[key] = hit; + } + found = true; + break; + } + } + if (!found) { + if (fs.existsSync(THREAD_BINDINGS_DIR)) { + for (const name of fs.readdirSync(THREAD_BINDINGS_DIR)) { + if (!name.endsWith('.json')) continue; + try { + const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BINDINGS_DIR, name), 'utf8')); + if (hit && hit.token === token && hit.bindingKey) { + bindingRegistry[hit.bindingKey] = { + token, + hostIp: hit.hostIp || ipFromKey(hit.bindingKey), + hub: hit.hub || hubEndpointLabel(), + updatedAt: hit.updatedAt || nowMs(), + }; + saveBindingRegistry(); + found = true; + break; + } + } catch {} + } + } + } + if (!found && !sessions.has(token)) { + return json(res, 404, { ok: false, error: 'no binding for token' }); + } + restoreSessionsFromBindings(); + saveSessions(); + return json(res, 200, { ok: true, token, restored: sessions.has(token) }); + } + + if (u.pathname === '/api/bindings/rebuild' && req.method === 'POST') { + const n = rebuildBindingRegistryFromThreadFiles(); + restoreSessionsFromBindings(); + return json(res, 200, { ok: true, rebuilt: n, sessions: sessions.size }); + } + + if (u.pathname === '/api/binding/context' && req.method === 'GET') { + const workspace = u.searchParams.get('workspace') || ''; + const cursorTitle = u.searchParams.get('cursorTitle') || u.searchParams.get('title') || ''; + const hostIp = u.searchParams.get('hostIp') || ''; + const planLabel = u.searchParams.get('planLabel') || ''; + const ctx = buildBindingContext(workspace, cursorTitle, hostIp, planLabel); + return json(res, 200, { ok: true, ...ctx }); + } + + if (u.pathname === '/api/plan/bind' && req.method === 'GET') { + const token = normalizeExactToken(u.searchParams.get('token') || ''); + if (!token) { + return json(res, 400, { ok: false, error: 'INVALID_TOKEN', hint: '须完整 ct_xxxxxxxx,禁止前缀泛搜' }); + } + const plan = getPlanByToken(token); + if (!plan) { + return json(res, 404, { ok: false, error: 'NO_PLAN_BINDING', token }); + } + return json(res, 200, { ok: true, ...plan }); + } + + if (u.pathname === '/api/cleanup-unused' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const r = cleanupUnusedPlans({ keepTokens: body.keepTokens || [] }); + return json(res, 200, { ok: true, ...r }); + } + + if (u.pathname === '/api/binding/rename' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const { token, cursorTitle, workspace, hostIp: bodyHostIp } = body; + if (!token || !cursorTitle) { + return json(res, 400, { ok: false, error: 'missing token or cursorTitle' }); + } + const mig = migrateBindingRename( + workspace || sessions.get(token)?.workspace, + token, + cursorTitle, + bodyHostIp || sessions.get(token)?.hostIp + ); + return json(res, 200, { ok: true, ...mig }); + } + + if (u.pathname === '/api/session/verify' && req.method === 'GET') { + const token = u.searchParams.get('token') || ''; + const workspace = u.searchParams.get('workspace') || ''; + const cursorTitle = u.searchParams.get('cursorTitle') || u.searchParams.get('title') || ''; + const hostIp = u.searchParams.get('hostIp') || ''; + if (!token) { + return json(res, 400, { ok: false, match: false, error: 'missing token' }); + } + const allowRename = u.searchParams.get('allowTitleRename') === '1'; + if (!sessions.has(token)) { + restoreSessionsFromBindings(); + } + const s = sessions.get(token); + if (!s) { + return json(res, 404, { + ok: false, + match: false, + token, + error: 'UNKNOWN_CONVERSATION_TOKEN', + sessionExists: false, + hint: 'Hub 会话库/绑定库都找不到该完整 token;此时才允许 init_conversation(newPlan:true) 新建计划。', + }); + } + const v = validateTokenBinding(token, workspace || s.workspace, cursorTitle || s.cursorTitle, hostIp || s.hostIp, { allowTitleRename: allowRename }); + const regToken = getRegistryToken(workspace || s.workspace, cursorTitle || s.cursorTitle, hostIp || s.hostIp); + const match = v.ok && (!regToken || regToken === token); + const tokenOwnerTitle = getTokenOwnerTitle(workspace || s.workspace, token); + const payload = { + ok: match, + match, + token, + expectedToken: regToken || token, + tokenOwnerTitle: tokenOwnerTitle || null, + bindingKey: makeBindingKey(workspace, cursorTitle, hostIp), + hostIp: normHostIp(hostIp || s?.hostIp), + hub: s?.hub || hubEndpointLabel(), + bindingUnchanged: !!match, + bindingCheckCount: s?.bindingCheckCount || 0, + sessionExists: sessions.has(token), + ...(v.ok ? v : v), + }; + return json(res, match ? 200 : 403, payload); + } + + if (u.pathname === '/api/session/goal' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const { token, projectGoal, completionPct } = body; + if (!token || !sessions.has(token)) { + return json(res, 404, { ok: false, error: 'unknown token' }); + } + const s = sessions.get(token); + if (typeof projectGoal === 'string') s.projectGoal = projectGoal.trim(); + if (typeof completionPct === 'number' && !Number.isNaN(completionPct)) { + s.completionPct = Math.max(0, Math.min(100, completionPct)); + } + s.lastActiveAt = nowMs(); + saveSessions(); + return json(res, 200, { + ok: true, + token, + projectGoal: s.projectGoal || '', + completionPct: s.completionPct ?? null, + }); + } + + if (u.pathname === '/api/binding' && req.method === 'GET') { + const workspace = u.searchParams.get('workspace') || ''; + const cursorTitle = u.searchParams.get('cursorTitle') || u.searchParams.get('title') || ''; + const hostIp = u.searchParams.get('hostIp') || ''; + const planLabel = u.searchParams.get('planLabel') || ''; + const token = (planLabel ? getTokenByPlan(workspace, cursorTitle, hostIp, planLabel) : null) + || getRegistryToken(workspace, cursorTitle, hostIp) + || findBoundSession(workspace, cursorTitle, hostIp, planLabel || null); + if (!token) { + return json(res, 404, { + ok: false, + error: 'no binding', + bindingKey: makeBindingKey(workspace, cursorTitle, hostIp), + planBindingKey: planLabel ? makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel) : '', + hostIp: normHostIp(hostIp), + hub: hubEndpointLabel(), + }); + } + const s = sessions.get(token); + return json(res, 200, { + ok: true, + token, + planLabel: s?.planLabel || planLabel || '', + bindingKey: makeBindingKey(workspace, cursorTitle, hostIp || s?.hostIp), + planBindingKey: planLabel ? makePlanBindingKey(workspace, cursorTitle, hostIp, planLabel) : (s?.planBindingKey || ''), + hostIp: s?.hostIp || normHostIp(hostIp), + hub: s?.hub || hubEndpointLabel(), + title: s?.title || cursorTitle, + workspace: s?.workspace || workspace, + }); + } + + if (u.pathname === '/api/new' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const workspace = body.workspace || ''; + const cursorTitle = body.cursorTitle || body.title || '新对话'; + const token = newToken(); + sessions.set(token, { + token, + title: cursorTitle, + cursorTitle, + bindingKey: makeBindingKey(workspace, cursorTitle), + workspace, + status: 'holding', + connectionHeld: true, + sessionAutoContinue: hubSettings.defaultSessionAutoContinue !== false, + createdAt: nowMs(), + lastActiveAt: nowMs(), + messages: [], + }); + saveSessions(); + pruneSessions(); + return json(res, 200, { ok: true, token }); + } + + + if (u.pathname === '/api/steer/delete' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const { token, index, all } = body; + if (!token || !sessions.has(token)) { + return json(res, 404, { ok: false, error: 'unknown token' }); + } + const s = sessions.get(token); + if (!Array.isArray(s.steerQueue) || !s.steerQueue.length) { + return json(res, 404, { ok: false, error: 'empty', hint: '无排队方向可删' }); + } + let removed = 0; + if (all) { + const texts = s.steerQueue.map((i) => i.text); + removed = s.steerQueue.length; + s.steerQueue = []; + pruneSteerFromMessages(s, texts); + } else if (Number.isInteger(index) && index >= 0 && index < s.steerQueue.length) { + const text = s.steerQueue[index].text; + s.steerQueue.splice(index, 1); + if (text) pruneSteerFromMessages(s, [text]); + removed = 1; + } else { + return json(res, 400, { ok: false, error: 'bad index' }); + } + s.lastActiveAt = nowMs(); + saveSessions(); + log('steer delete', token, { removed, remain: (s.steerQueue || []).length }); + return json(res, 200, { + ok: true, + token, + removed, + queueLen: (s.steerQueue || []).length, + }); + } + + if (u.pathname === '/api/delete' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const { token, force } = body; + if (!token) return json(res, 400, { ok: false, error: 'missing token' }); + const r = deleteSession(token, !!force); + if (!r.ok) return json(res, 409, r); + pruneSessions(); + return json(res, 200, r); + } + + if (u.pathname === '/api/delete-idle' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const keep = body.keep || null; + const maxIdleHours = Math.max(1, Number(body.maxIdleHours) || 72); + const maxIdleMs = maxIdleHours * 3600000; + const now = nowMs(); + const protectedTokens = new Set(pendingWaits.keys()); + if (keep) protectedTokens.add(keep); + for (const hit of Object.values(bindingRegistry)) { + if (hit && hit.token && pendingWaits.has(hit.token)) protectedTokens.add(hit.token); + } + let deleted = 0; + const deletedTokens = []; + for (const [token, s] of [...sessions.entries()]) { + if (protectedTokens.has(token)) continue; + if (pendingWaits.has(token)) continue; + const age = now - (s.lastActiveAt || s.createdAt || now); + const idleLike = s.status === 'idle' || s.status === 'completed' || !(s.messages || []).length; + if (!idleLike) continue; + if (age < maxIdleMs && (s.messages || []).length > 0) continue; + if (isVerticallyLockedToken(token) && age < maxIdleMs * 2) continue; + sessions.delete(token); + deletedTokens.push(token); + deleted++; + } + if (deleted) saveSessions(); + pruneSessions(); + log('delete-idle', { deleted, remain: sessions.size, maxIdleHours, deletedTokens }); + return json(res, 200, { ok: true, deleted, remain: sessions.size, maxIdleHours }); + } + + if (u.pathname === '/api/prune' && req.method === 'POST') { + const n = pruneSessions(); + return json(res, 200, { ok: true, pruned: n, remain: sessions.size }); + } + + if (u.pathname === '/api/merge' && req.method === 'POST') { + const body = JSON.parse(await readBody(req) || '{}'); + const src = sessions.get(body.source); + const tgt = sessions.get(body.target); + if (!src || !tgt) return json(res, 400, { ok: false, error: 'invalid token' }); + (tgt.messages = tgt.messages || []).push(...(src.messages || [])); + src.status = 'completed'; + tgt.lastActiveAt = nowMs(); + saveSessions(); + return json(res, 200, { ok: true }); + } + + res.writeHead(404); res.end('not found'); + } catch (e) { + log('http err', String(e.stack || e.message || e)); + try { json(res, 500, { error: String(e.message || e) }); } catch {} + } +}); + +server.on('error', (err) => { + log('server listen error', err && err.code, err && err.message); + process.exit(1); +}); + +server.listen(HTTP_PORT, '127.0.0.1', () => { + log('hub listening', HTTP_PORT, 'pid', process.pid); + try { archiveStaleSessions(); } catch (e) { log('archive boot err', String(e.message || e)); } +}); + +setInterval(() => { + try { archiveStaleSessions(); } catch (e) { log('archive tick err', String(e.message || e)); } +}, 5 * 60 * 1000); + +setInterval(() => { + let changed = false; + for (const [token, s] of sessions.entries()) { + if (s.status === 'completed') continue; + if (!effectiveAutoContinue(s)) continue; + if (pendingWaits.has(token)) { + if (!s.connectionHeld) { + s.connectionHeld = true; + changed = true; + } + s.lastKeepAliveAt = nowMs(); + if (s.status !== 'waiting') { s.status = 'waiting'; changed = true; } + } else { + if (s.connectionHeld) { + s.connectionHeld = false; + changed = true; + } + if (s.status === 'holding') { + s.status = 'idle'; + changed = true; + } + } + } + if (changed) saveSessions(); +}, CONNECTION_KEEPALIVE_MS); + +process.on('SIGTERM', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); diff --git a/src/server.js b/src/server.js new file mode 100755 index 0000000..dd7de19 --- /dev/null +++ b/src/server.js @@ -0,0 +1,1129 @@ +#!/usr/bin/env node +/** + * Local Persistent Chat — MCP stdio client + * HTTP/面板由 hub.js 独占;本进程只负责 MCP 工具,通过 Hub API 挂起/收回复。 + * 每个 Agent 线程(工作区+标签名)唯一锁定一个 pchat-token,禁止跨会话串数据。 + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const os = require('os'); +const readline = require('readline'); +const { resolveCursorTabTitle, writeActiveVertical, normTitle: normTabTitle } = require('./cursor-title'); + +const HOME = process.env.HOME || process.env.USERPROFILE; +const ROOT = path.join(HOME, '.persistent-chat-local'); +const LOG_DIR = path.join(ROOT, 'logs'); +const THREAD_BIND_DIR = path.join(ROOT, 'thread-bindings'); + +for (const d of [ROOT, LOG_DIR, THREAD_BIND_DIR]) { + try { fs.mkdirSync(d, { recursive: true }); } catch {} +} + +const argv = process.argv.slice(2); +function arg(key, fallback) { + const hit = argv.find((a) => a.startsWith(`--${key}=`)); + return hit ? hit.slice(key.length + 3) : fallback; +} +const WORKSPACE_RAW = arg('workspace', process.cwd()); +const WORKSPACE = (!WORKSPACE_RAW + || WORKSPACE_RAW === 'none' + || String(WORKSPACE_RAW).includes('${workspaceFolder}')) + ? (process.cwd() || HOME) + : WORKSPACE_RAW; +const HTTP_PORT = parseInt(process.env.PCHAT_HTTP_PORT || arg('port', '13458'), 10); +const HUB_HOST = process.env.PCHAT_HUB_HOST || '127.0.0.1'; +const HUB = `http://${HUB_HOST}:${HTTP_PORT}`; +const HOST_IP = getHostIp(); +const HUB_LABEL = `${HUB_HOST}:${HTTP_PORT}`; +/** 卡若AI 能力真源(每轮对话门禁) */ +const KARUO_AI_ROOT = '/Users/karuo/Documents/个人/卡若AI'; + +function getHostIp() { + const ifs = os.networkInterfaces(); + for (const name of Object.keys(ifs)) { + for (const iface of ifs[name] || []) { + if (iface.family === 'IPv4' && !iface.internal) return iface.address; + } + } + return '127.0.0.1'; +} + +function hubMeta() { + return { hostIp: HOST_IP, hub: HUB_LABEL }; +} + +const logFile = path.join(LOG_DIR, 'server.log'); +function log(...parts) { + const line = `[${new Date().toISOString()}] [ws=${WORKSPACE}] ${parts.map((p) => (typeof p === 'string' ? p : JSON.stringify(p))).join(' ')}\n`; + try { fs.appendFileSync(logFile, line); } catch {} +} +log('boot mcp client', { hub: HUB, pid: process.pid }); + +function normWorkspace(ws) { + return String(ws || '').replace(/\/+$/, '') || '(unknown)'; +} + +function normTitle(title) { + return normTabTitle(title); +} + +/** 真源:Cursor 标签名(args > vscdb > 本标签 thread 文件;禁止跨标签用进程锁) */ +function resolveCursorTitle(args, token) { + const hit = resolveCursorTabTitle({ + cursorTitle: args.cursorTitle || args.title, + title: args.title, + workspace: WORKSPACE, + composerId: process.env.CURSOR_CONVERSATION_ID, + }); + if (hit.title && hit.title !== '(未命名标签)') { + return { ...hit, title: normTitle(hit.title) }; + } + const argTitle = normTitle(args.cursorTitle || args.title || ''); + if (argTitle && argTitle !== '(未命名标签)') { + return { title: argTitle, source: 'args', composerId: hit.composerId || null }; + } + if (token) { + const local = loadThreadBindingByToken(token); + if (local?.cursorTitle) { + return { title: normTitle(local.cursorTitle), source: 'tokenFile', composerId: local.composerId || hit.composerId || null }; + } + } + return { title: '', source: 'missing', composerId: hit.composerId || null }; +} + +async function syncTitleBinding(token, cursorTitle, composerId) { + if (!cursorTitle || !token) return null; + try { + return await hubJson('/api/binding/sync-title', { + method: 'POST', + body: JSON.stringify({ + token, + cursorTitle, + composerId: composerId || '', + workspace: WORKSPACE, + hostIp: HOST_IP, + }), + }); + } catch { + return null; + } +} + +async function maybeRenameBinding(token, cursorTitle, composerId) { + if (!token || !cursorTitle) return null; + try { + return await hubJson('/api/binding/rename', { + method: 'POST', + body: JSON.stringify({ + token, + cursorTitle, + composerId: composerId || '', + workspace: WORKSPACE, + hostIp: HOST_IP, + }), + }); + } catch { + return null; + } +} + +function makeBindingKey(workspace, cursorTitle, hostIp) { + const ip = String(hostIp || HOST_IP).trim() || HOST_IP; + return `${normWorkspace(workspace)}::${normTitle(cursorTitle)}@${ip}`; +} + +function threadBindingPath(cursorTitle) { + const key = makeBindingKey(WORKSPACE, cursorTitle); + const hash = crypto.createHash('sha256').update(key).digest('hex').slice(0, 20); + return path.join(THREAD_BIND_DIR, `${hash}.json`); +} + +function saveThreadBinding(cursorTitle, token, extra = {}) { + if (!cursorTitle || !token) return; + const payload = { + token, + cursorTitle: normTitle(cursorTitle), + workspace: normWorkspace(WORKSPACE), + bindingKey: makeBindingKey(WORKSPACE, cursorTitle, extra.hostIp || HOST_IP), + hostIp: extra.hostIp || HOST_IP, + hub: extra.hub || HUB_LABEL, + composerId: extra.composerId || '', + verticalLock: true, + updatedAt: Date.now(), + pid: process.pid, + ...extra, + }; + fs.writeFileSync(threadBindingPath(cursorTitle), JSON.stringify(payload, null, 2)); +} + +function loadThreadBindingByToken(token) { + if (!token) return null; + try { + for (const f of fs.readdirSync(THREAD_BIND_DIR)) { + if (!f.endsWith('.json')) continue; + const hit = JSON.parse(fs.readFileSync(path.join(THREAD_BIND_DIR, f), 'utf8')); + if (hit?.token === token) return hit; + } + } catch {} + return null; +} + +async function verifyVerticalBinding(token, cursorTitle) { + const title = normTitle(cursorTitle || threadBoundTitle || ''); + if (!token) { + return { ok: false, text: 'BINDING_MISSING_TOKEN: wait/init 必须带 pchat-token。' }; + } + if (!title || title === '(未命名标签)') { + return { ok: false, text: 'BINDING_MISSING_TITLE: wait/init 必须带 title=cursorTitle(当前 Cursor 标签名)。' }; + } + try { + const q = new URLSearchParams({ + token, + workspace: WORKSPACE, + cursorTitle: title, + hostIp: HOST_IP, + allowTitleRename: '1', + }); + const v = await hubJson(`/api/session/verify?${q.toString()}`); + if (!v.match) { + return { + ok: false, + text: + `VERTICAL_BINDING_BROKEN: ct_${token.slice(3)} 与标签「${title}」@ ${HOST_IP} 未对齐。\n` + + `期望 token: ${v.expectedToken || '见 Hub 注册表'}。\n` + + `修复:本对话全程只用 init 返回的 ct_;换计划用 newPlan:true;禁止串 token。`, + }; + } + lockThread(token, title); + saveThreadBinding(title, token, { hostIp: v.hostIp || HOST_IP, hub: v.hub || HUB_LABEL }); + return { + ok: true, + token, + title, + bindingKey: v.bindingKey, + hostIp: v.hostIp || HOST_IP, + hub: v.hub || HUB_LABEL, + bindingUnchanged: v.bindingUnchanged !== false, + bindingCheckCount: v.bindingCheckCount || 0, + }; + } catch (e) { + if (e.status === 403 && e.data) { + return { + ok: false, + text: + `VERTICAL_BINDING_BROKEN: ${e.data.error || 'verify failed'}\n` + + `${e.data.hint || ''}\n期望: ${e.data.expected || e.data.expectedToken || '?'}`, + }; + } + throw e; + } +} + +function bindingFingerprint(token, cursorTitle, verify) { + const t = normTitle(cursorTitle || threadBoundTitle || ''); + const ok = verify && verify.ok !== false; + const n = verify?.bindingCheckCount != null ? verify.bindingCheckCount : '—'; + return ( + `[绑定指纹·${ok ? '不变' : '异常'}] IP=${HOST_IP} | Hub=${HUB} | ct=${token} | 标签=${t} | 校验#${n}` + ); +} + +function bindingLockLine(token, cursorTitle, verify) { + const t = normTitle(cursorTitle || threadBoundTitle || ''); + const fp = bindingFingerprint(token, t, verify || { ok: true }); + return `🔒 [垂直绑定] ${token} ↔「${t}」@ ${HOST_IP} · ${fp}`; +} + +function loadThreadBinding(cursorTitle) { + if (!cursorTitle) return null; + try { + return JSON.parse(fs.readFileSync(threadBindingPath(cursorTitle), 'utf8')); + } catch { + return null; + } +} + +/** 本 MCP 进程内锁定的 token(一个 Agent 线程唯一) */ +let threadBoundToken = null; +let threadBoundTitle = null; + +function lockThread(token, cursorTitle, extra = {}) { + threadBoundToken = token; + if (cursorTitle) { + threadBoundTitle = normTitle(cursorTitle); + saveThreadBinding(cursorTitle, token, { hostIp: HOST_IP, hub: HUB_LABEL, ...extra }); + writeActiveVertical({ + composerId: extra.composerId || '', + title: threadBoundTitle, + workspace: WORKSPACE, + token, + }); + } + log('thread lock', { token, cursorTitle: threadBoundTitle, hostIp: HOST_IP, composerId: extra.composerId || '' }); +} + +async function hubBindingToken(cursorTitle) { + if (!cursorTitle || cursorTitle === '(未命名标签)') return null; + try { + const q = new URLSearchParams({ workspace: WORKSPACE, cursorTitle, hostIp: HOST_IP }); + const b = await hubJson(`/api/binding?${q.toString()}`); + return b.token || null; + } catch { + return null; + } +} + +async function hubBindingContext(cursorTitle, planLabel) { + const q = new URLSearchParams({ workspace: WORKSPACE, cursorTitle, hostIp: HOST_IP }); + if (planLabel) q.set('planLabel', planLabel); + return hubJson(`/api/binding/context?${q.toString()}`); +} + +function parseChoiceToken(choiceText) { + const m = String(choiceText || '').match(/ct_[a-f0-9]{8}/i); + return m ? m[0].toLowerCase() : null; +} + +function askOptionsFromCtx(ctx, titleNorm, opts = {}) { + const { recommendedToken, excludeToken, reason } = opts; + const options = []; + const canonical = recommendedToken || ctx.boundToken || null; + if (canonical) { + options.push( + `🔄 恢复正确任务: ${canonical} (${titleNorm}) [与本标签匹配·推荐]` + ); + } + options.push(`🆕 新建本标签专用: ${titleNorm} (init newPlan)`); + for (const s of ctx.activeSessions || []) { + if (!s.token || s.token === canonical || s.token === excludeToken) continue; + const label = s.cursorTitle || s.title || '未命名'; + options.push( + `🔄 恢复其它任务: ${s.token} (${label}) ${s.isActiveWait ? '[当前活跃]' : ''} ${s.status === 'waiting' ? '[等待中]' : ''}` + ); + } + return { options, canonical, reason: reason || ctx.mode || 'choice' }; +} + +async function formatMismatchChoice(cursorTitle, requestedToken, reason, extra = {}) { + const titleNorm = normTitle(cursorTitle); + const ctx = await hubBindingContext(titleNorm); + const owner = requestedToken ? await hubTokenOwnerTitle(requestedToken) : (extra.ownerTitle || null); + const { options, canonical } = askOptionsFromCtx(ctx, titleNorm, { + recommendedToken: ctx.boundToken, + excludeToken: null, + reason, + }); + const lines = [ + '[BINDING_MISMATCH]', + `reason: ${reason}`, + `当前 Cursor 标签(Agent 应对应的任务): ${titleNorm}`, + requestedToken ? `Agent 当前误用 token: ${requestedToken}` : '', + owner ? `该 token 实际绑定任务: ${owner}` : '', + extra.processLock ? `MCP 进程误锁: ${extra.processLock}` : '', + `hostIp: ${ctx.hostIp}`, + `hub: ${ctx.hubUrl}`, + canonical ? `本标签正确 token: ${canonical}` : '', + '', + '⚠️ 任务与 token 不一致 — 必须 ask_user_question 让用户选,禁止自行猜测:', + '问题: 请选择与本 Cursor 标签一致的持久对话任务', + '', + 'ASK_OPTIONS_JSON:', + JSON.stringify(options), + '', + ...options.map((o) => `OPTION: ${o}`), + '', + '用户选完后调用 apply_binding_choice(choice=用户原文, cursorTitle=当前标签名)', + '[transport: codeblock]', + ].filter(Boolean); + return lines.join('\n'); +} + +function formatBindingChoice(ctx, titleNorm) { + const { options } = askOptionsFromCtx(ctx, titleNorm, { reason: 'choice' }); + const lines = [ + '[BINDING_CHOICE_REQUIRED]', + `hostIp: ${ctx.hostIp}`, + `hub: ${ctx.hubUrl}`, + `cursorTitle: ${titleNorm}`, + '本工作区有其它活跃任务,须 ask_user_question 让用户选择:', + '问题: 请选择要绑定的持久对话任务', + '', + 'ASK_OPTIONS_JSON:', + JSON.stringify(options), + '', + ...options.map((o) => `OPTION: ${o}`), + '', + '用户选完后调用 apply_binding_choice(choice=用户原文, cursorTitle=当前标签名)', + '[transport: codeblock]', + ]; + return lines.join('\n'); +} + +async function resolveOrPromptChoice(resolved, cursorTitle) { + if (resolved.ok) return null; + if (resolved.needsChoice) { + return makeContentText( + await formatMismatchChoice( + resolved.cursorTitle || cursorTitle, + resolved.requestedToken, + resolved.reason, + { ownerTitle: resolved.ownerTitle, processLock: resolved.processLock } + ) + ); + } + if (resolved.text) return makeContentText(resolved.text); + return null; +} + +function formatBindingReuseText(ctx, titleNorm, token) { + const plan = ctx.planLabel ? `\nplanLabel: ${ctx.planLabel}` : ''; + return ( + `[BINDING_REUSE]\n` + + `hostIp: ${ctx.hostIp}\n` + + `hub: ${ctx.hubUrl}\n` + + `bindingKey: ${ctx.bindingKey}${plan}\n` + + `本标签+本机IP已绑定: ${token} ↔ 「${titleNorm}」\n` + + `🔄 恢复对话: ${token} (${titleNorm}) [已绑定·完整 token·勿换]\n` + + `${bindingLockLine(token, titleNorm, { ok: true })}\n` + + `[transport: codeblock]` + ); +} + +async function hubTokenOwnerTitle(token) { + if (!token) return null; + try { + const q = new URLSearchParams({ token, workspace: WORKSPACE }); + const v = await hubJson(`/api/session/verify?${q.toString()}`); + if (v.tokenOwnerTitle) return normTitle(v.tokenOwnerTitle); + const s = await hubJson('/api/state'); + const hit = (s.sessions || []).find((x) => x.token === token); + if (hit?.cursorTitle) return normTitle(hit.cursorTitle); + } catch {} + return null; +} + +async function hubEnsureTokenExists(token, cursorTitle) { + if (!token) return { ok: false, error: 'missing token' }; + try { + await hubJson('/api/session/restore', { + method: 'POST', + body: JSON.stringify({ token }), + }); + } catch (e) { + if (e.status !== 404) throw e; + } + try { + const q = new URLSearchParams({ + token, + workspace: WORKSPACE, + cursorTitle: normTitle(cursorTitle || ''), + hostIp: HOST_IP, + }); + const v = await hubJson(`/api/session/verify?${q.toString()}`); + return { ok: !!v.sessionExists, verify: v }; + } catch (e) { + if (e.status === 404) { + return { ok: false, error: e.data?.error || 'UNKNOWN_CONVERSATION_TOKEN', hint: e.data?.hint || '' }; + } + if (e.status === 403 && e.data?.sessionExists === false) { + return { ok: false, error: e.data.error || 'UNKNOWN_CONVERSATION_TOKEN', hint: e.data.hint || '' }; + } + if (e.status === 403) return { ok: true, verifyError: e.data }; + throw e; + } +} + +async function resolveThreadToken(requestedToken, cursorTitle, composerId) { + const title = normTitle(cursorTitle || ''); + const explicitTitle = title && title !== '(未命名标签)'; + + // 同工作区多标签共用一个 MCP 进程:必须以「当前标签 + composerId」为准 + if (explicitTitle) { + if (composerId) { + try { + const sync = await hubJson('/api/binding/sync-title', { + method: 'POST', + body: JSON.stringify({ + token: requestedToken || '', + cursorTitle: title, + composerId, + workspace: WORKSPACE, + hostIp: HOST_IP, + }), + }); + if (sync?.token) { + lockThread(sync.token, title, { composerId }); + return { ok: true, token: sync.token, titleRenamed: !!sync.renamed }; + } + } catch {} + } + const hubTok = await hubBindingToken(title); + const local = loadThreadBinding(title); + const canonical = hubTok || local?.token || null; + if (canonical) { + if (requestedToken && requestedToken !== canonical) { + return { + ok: false, + needsChoice: true, + reason: 'TOKEN_TITLE_MISMATCH', + cursorTitle: title, + requestedToken, + }; + } + lockThread(canonical, title, { composerId }); + return { ok: true, token: canonical }; + } + if (requestedToken) { + const exists = await hubEnsureTokenExists(requestedToken, title); + if (!exists.ok) { + return { + ok: false, + text: + `UNKNOWN_CONVERSATION_TOKEN: ${requestedToken} 不在 Hub 会话库/绑定库中。\n` + + `请先用完整 token 恢复;若库里确实没有,再 init_conversation(newPlan:true, cursorTitle=当前标签名, planLabel=计划名)。\n` + + `[transport: codeblock]`, + }; + } + const owner = await hubTokenOwnerTitle(requestedToken); + if (owner && owner !== title) { + return { + ok: false, + needsChoice: true, + reason: 'TOKEN_BOUND_TO_OTHER_TITLE', + cursorTitle: title, + requestedToken, + ownerTitle: owner, + }; + } + lockThread(requestedToken, title, { composerId }); + return { ok: true, token: requestedToken }; + } + } + + if (threadBoundToken && threadBoundTitle) { + if (explicitTitle && title !== threadBoundTitle) { + return { + ok: false, + needsChoice: true, + reason: 'PROCESS_LOCK_WRONG_TASK', + cursorTitle: title, + requestedToken: requestedToken || threadBoundToken, + processLock: `${threadBoundToken}(${threadBoundTitle})`, + }; + } + if (requestedToken && requestedToken !== threadBoundToken) { + return { + ok: false, + needsChoice: true, + reason: 'TOKEN_THREAD_MISMATCH', + cursorTitle: title || threadBoundTitle, + requestedToken, + processLock: `${threadBoundToken}(${threadBoundTitle})`, + }; + } + return { ok: true, token: threadBoundToken }; + } + + if (requestedToken) { + const exists = await hubEnsureTokenExists(requestedToken, title || threadBoundTitle || ''); + if (!exists.ok) { + return { + ok: false, + text: + `UNKNOWN_CONVERSATION_TOKEN: ${requestedToken} 不在 Hub 会话库/绑定库中。\n` + + `找不到完整 token 时,才允许新建计划:init_conversation(newPlan:true, cursorTitle=标签名, planLabel=计划名)。\n` + + `[transport: codeblock]`, + }; + } + if (explicitTitle) lockThread(requestedToken, title, { composerId }); + return { ok: true, token: requestedToken }; + } + return { + ok: false, + text: 'UNKNOWN_CONVERSATION_TOKEN: 请 init_conversation(cursorTitle=当前 Cursor 标签名) 获取本标签专用 ct_。', + }; +} + +async function hubJson(pathname, opts = {}) { + const url = HUB + pathname; + const res = await fetch(url, { + ...opts, + headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, + }); + const text = await res.text(); + let data = {}; + try { data = text ? JSON.parse(text) : {}; } catch { data = { raw: text }; } + if (!res.ok) { + const err = new Error(data.error || data.hint || `HTTP ${res.status}`); + err.status = res.status; + err.data = data; + throw err; + } + return data; +} + +async function hubPollWait(token, onReRegister) { + let hubRetries = 0; + let goneRetries = 0; + while (true) { + try { + const res = await fetch(`${HUB}/api/wait/poll?token=${encodeURIComponent(token)}`, { + signal: AbortSignal.timeout(35000), + }); + const data = await res.json(); + hubRetries = 0; + if (data.status === 'replied') return data.reply; + if (data.status === 'renewal') continue; + if (data.status === 'gone') { + if (typeof onReRegister === 'function' && goneRetries < 8) { + goneRetries++; + log('wait gone, re-register', { token, attempt: goneRetries }); + await onReRegister(); + continue; + } + throw new Error('wait gone: ' + token); + } + throw new Error('unexpected poll: ' + JSON.stringify(data)); + } catch (e) { + const msg = String(e.message || e); + const transient = /fetch failed|ECONNREFUSED|ECONNRESET|socket hang up/i.test(msg); + if (transient && hubRetries < 60) { + hubRetries++; + await new Promise((r) => setTimeout(r, 300)); + continue; + } + throw e; + } + } +} + +function send(obj) { + process.stdout.write(JSON.stringify(obj) + '\n'); +} + +function makeContentText(text) { + return { content: [{ type: 'text', text: String(text) }] }; +} + +function makeError(id, code, message) { + return { jsonrpc: '2.0', id, error: { code, message } }; +} + +function clearThreadBinding(cursorTitle) { + if (!cursorTitle) return; + threadBoundToken = null; + threadBoundTitle = null; + try { fs.unlinkSync(threadBindingPath(cursorTitle)); } catch {} + log('thread binding cleared', { cursorTitle: normTitle(cursorTitle) }); +} + +const TOOLS = [ + { + name: 'init_conversation', + description: '创建/复用持久对话。同标签续同一计划可复用;新计划必须 newPlan:true 新建 ct_,禁止挂旧线程。', + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Cursor Chat 窗口标题(标签名,必填)' }, + cursorTitle: { type: 'string', description: '同 title' }, + force: { type: 'boolean', description: '强制新建(同 newPlan)' }, + newPlan: { + type: 'boolean', + description: '新计划/新任务:归档同标签旧 ct_,创建新线程,禁止复用 Codex网关修复等旧会话', + }, + planLabel: { type: 'string', description: '新计划简称,写入会话标题(如「自传小说」)' }, + }, + required: [], + }, + }, + { + name: 'select_conversation', + description: '检查绑定;任务/token 不匹配时返回 BINDING_MISMATCH 供 ask_user_question 选人。', + inputSchema: { + type: 'object', + properties: { + newPlan: { type: 'boolean', description: 'true 时跳过旧锁,等价于 init newPlan' }, + planLabel: { type: 'string' }, + cursorTitle: { type: 'string' }, + title: { type: 'string' }, + 'pchat-token': { type: 'string', description: '可选;若与标签不匹配则弹出任务选择' }, + }, + required: [], + }, + }, + { + name: 'wait_for_user_input', + description: '挂起 Agent,等待本地面板 http://127.0.0.1:13458 回复。必须使用本线程锁定的 pchat-token。', + inputSchema: { + type: 'object', + properties: { + 'pchat-token': { type: 'string' }, + message: { type: 'string' }, + reply_file: { type: 'string' }, + prompt: { type: 'string' }, + title: { type: 'string', description: 'Cursor 标签名(必填,与 init 一致)' }, + }, + required: ['pchat-token', 'message'], + }, + }, + { + name: 'verify_karuo_ai_gate', + description: + '卡若AI 每轮门禁:校验 BOOTSTRAP/AGENTS 可读 +(可选)垂直绑定。每对话至少调用一次。', + inputSchema: { + type: 'object', + properties: { + 'pchat-token': { type: 'string', description: '当前 ct_;传入则同时 verify_binding' }, + title: { type: 'string' }, + cursorTitle: { type: 'string' }, + }, + required: [], + }, + }, + { + name: 'verify_binding', + description: '校验 pchat-token 与 Cursor 标签名垂直绑定是否一致(wait 前可主动调)。', + inputSchema: { + type: 'object', + properties: { + 'pchat-token': { type: 'string' }, + title: { type: 'string' }, + cursorTitle: { type: 'string' }, + }, + required: ['pchat-token'], + }, + }, + { + name: 'apply_binding_choice', + description: '用户经 ask_user_question 选定任务后,应用绑定(解析选项中的 ct_ 或新建)。', + inputSchema: { + type: 'object', + properties: { + choice: { type: 'string', description: '用户选择的完整选项原文' }, + cursorTitle: { type: 'string', description: '当前 Cursor 标签名(必填)' }, + title: { type: 'string' }, + }, + required: ['choice', 'cursorTitle'], + }, + }, + { + name: 'merge_conversation', + description: '合并会话(已锁定线程时禁止)。', + inputSchema: { + type: 'object', + properties: { source: { type: 'string' }, target: { type: 'string' } }, + required: ['source', 'target'], + }, + }, +]; + +async function handleToolCall(name, args) { + args = args || {}; + + if (name === 'init_conversation') { + try { + const titleHit = resolveCursorTitle(args); + const cursorTitle = titleHit.title || ''; + if (!cursorTitle || cursorTitle === '(未命名标签)') { + return makeContentText( + 'BINDING_MISSING_TITLE: 未读到 Cursor 标签名。请传 title/cursorTitle,或确认 Hook active-vertical / state.vscdb 可用。\n[transport: codeblock]' + ); + } + const newPlan = !!(args.newPlan || args.force); + if (newPlan) clearThreadBinding(cursorTitle); + const r = await hubJson('/api/init', { + method: 'POST', + body: JSON.stringify({ + title: cursorTitle || '新对话', + cursorTitle, + composerId: titleHit.composerId || '', + workspace: WORKSPACE, + force: newPlan, + newPlan, + planLabel: args.planLabel || '', + ...hubMeta(), + }), + }); + lockThread(r.token, cursorTitle, { planLabel: args.planLabel || r.planLabel || '', composerId: titleHit.composerId || '' }); + log('init_conversation', r.token, r.reused ? 'reused' : (newPlan ? 'new_plan' : 'new')); + const reuseNote = r.reused ? '\n(已复用同标签+同IP,勿再 init)' : ''; + const planNote = r.newPlan ? `\n🆕 新计划线程:${r.token}(旧 ct_ 已归档)` : ''; + const bind = bindingLockLine(r.token, cursorTitle, { ok: true, bindingCheckCount: 1 }); + const modeNote = r.bindingMode === 'auto_new' ? '\n[BINDING_AUTO_NEW] 新对话已自动绑定,全程勿换。' : ''; + return makeContentText( + `Conversation initialized. pchat-token: ${r.token}${reuseNote}${planNote}${modeNote}\n` + + `${bind}\n` + + `[transport: codeblock]\n` + + `cursorTitle: ${cursorTitle}(来源: ${titleHit.source})\n` + + `线程绑定: ${r.bindingKey || makeBindingKey(WORKSPACE, cursorTitle, HOST_IP)}\n` + + `hostIp: ${r.hostIp || HOST_IP} · hub: ${HUB}\n` + + `本 Agent 垂直锁定:同一 ct_ + 同一 Cursor 标签 + 同一 IP;标签改名自动迁移 binding。\n` + + `⚠️ 换计划用 newPlan:true;禁止中途换 ct_。\n` + + `本地回复面板: ${HUB.replace('/api', '')}/` + ); + } catch (e) { + if (e.status === 409) { + return makeContentText( + `SESSION_LIST_NOT_EMPTY: 存在 ${e.data.count || '?'} 个活跃会话。\n` + + `请 init 时传 cursorTitle=当前标签名 自动复用绑定;勿 force 乱建。\n` + + `仅当用户明确说「新建持久对话」时才 force=true。` + ); + } + throw e; + } + } + + if (name === 'apply_binding_choice') { + const titleHit = resolveCursorTitle(args); + const cursorTitle = titleHit.title || args.cursorTitle || args.title || ''; + const titleNorm = normTitle(cursorTitle); + const choice = String(args.choice || ''); + if (!titleNorm || titleNorm === '(未命名标签)') { + return makeContentText('BINDING_CHOICE_FAIL: 必须传 cursorTitle=当前 Cursor 标签名'); + } + if (/新建|newPlan|新计划|新标签专用|🆕/i.test(choice)) { + return handleToolCall('init_conversation', { + cursorTitle: titleNorm, + title: titleNorm, + newPlan: true, + planLabel: args.planLabel || '', + }); + } + const token = parseChoiceToken(choice); + if (!token) { + return makeContentText( + 'BINDING_CHOICE_PARSE_FAIL: 无法解析 ct_。请让用户重选,或选「🆕 新建本标签专用」。\n[transport: codeblock]' + ); + } + const owner = await hubTokenOwnerTitle(token); + if (owner && owner !== titleNorm) { + const canonical = await hubBindingToken(titleNorm); + if (token !== canonical) { + return makeContentText( + await formatMismatchChoice(titleNorm, token, 'USER_PICKED_WRONG_TASK', { ownerTitle: owner }) + ); + } + } + lockThread(token, titleNorm); + const v = await verifyVerticalBinding(token, titleNorm); + if (!v.ok) return makeContentText(v.text + '\n[transport: codeblock]'); + return makeContentText( + `✅ 已绑定任务\n${bindingLockLine(v.token, v.title, v)}\n` + + `pchat-token: ${token}(全程勿换)\n[transport: codeblock]` + ); + } + + if (name === 'select_conversation') { + const titleHit = resolveCursorTitle(args, args['pchat-token']); + const cursorTitle = titleHit.title || args.cursorTitle || args.title || threadBoundTitle || ''; + const titleNorm = normTitle(cursorTitle); + const planLabel = String(args.planLabel || '').trim(); + if (args.newPlan) { + return handleToolCall('init_conversation', { + ...args, + newPlan: true, + cursorTitle, + title: cursorTitle, + planLabel, + }); + } + if (titleNorm && titleNorm !== '(未命名标签)' && args['pchat-token']) { + const pre = await resolveThreadToken(args['pchat-token'], titleNorm, titleHit.composerId); + const prompt = await resolveOrPromptChoice(pre, titleNorm); + if (prompt) return prompt; + } + if (titleNorm && titleNorm !== '(未命名标签)') { + const ctx = await hubBindingContext(titleNorm, planLabel); + if (ctx.mode === 'reuse' && ctx.boundToken) { + lockThread(ctx.boundToken, titleNorm, { composerId: titleHit.composerId || '', planLabel: ctx.planLabel || planLabel }); + return makeContentText(formatBindingReuseText(ctx, titleNorm, ctx.boundToken)); + } + if (ctx.mode === 'auto_new') { + return handleToolCall('init_conversation', { + cursorTitle: titleNorm, + title: titleNorm, + newPlan: false, + planLabel, + }); + } + if (ctx.mode === 'choice') { + const matched = (ctx.activeSessions || []).filter( + (x) => normTitle(x.cursorTitle || x.title) === titleNorm + ); + if (matched.length === 1) { + lockThread(matched[0].token, titleNorm, { composerId: titleHit.composerId || '', planLabel: matched[0].planLabel || planLabel }); + return makeContentText(formatBindingReuseText(ctx, titleNorm, matched[0].token)); + } + const lines = matched.map((s) => { + const pl = s.planLabel ? ` · 计划:${s.planLabel}` : ''; + return `🔄 恢复对话: ${s.token} (${s.cursorTitle || s.title || '未命名'})${pl} ${s.isActiveWait ? '[当前活跃]' : ''} ${s.status === 'waiting' ? '[等待中]' : ''}(完整 token·如不确定请勿选择)`; + }); + if (lines.length > 0) { + return makeContentText( + `[BINDING_CHOICE] cursorTitle: ${titleNorm}\n` + + lines.join('\n') + + `\n⚠️ 须选完整 ct_xxxxxxxx,禁止前缀泛搜。\n[transport: codeblock]` + ); + } + return makeContentText(formatBindingChoice(ctx, titleNorm)); + } + } + if (threadBoundToken && threadBoundTitle) { + return makeContentText( + `本 MCP 进程已锁定: ${threadBoundToken}(标签: ${threadBoundTitle})\n` + + `🔄 恢复对话: ${threadBoundToken} (${threadBoundTitle}) [当前线程·完整 token](如不确定请勿选择)\n` + + `⚠️ 新标签/新任务 → init_conversation(newPlan:true, planLabel=计划名, cursorTitle=标签名)\n` + + `[transport: codeblock]` + ); + } + const st = await hubJson('/api/state'); + const wsNorm = WORKSPACE.replace(/\/+$/, ''); + let list = (st.sessions || []).filter((s) => s.status !== 'completed') + .filter((s) => { + const sw = String(s.workspace || '').replace(/\/+$/, ''); + return !sw || sw === wsNorm; + }); + if (titleNorm && titleNorm !== '(未命名标签)') { + list = list.filter((s) => normTitle(s.cursorTitle || s.title || '') === titleNorm); + } + const lines = list.map((s) => { + const pl = s.planLabel ? ` · 计划:${s.planLabel}` : ''; + return `🔄 恢复对话: ${s.token} (${s.title || s.cursorTitle || '未命名'})${pl} ${s.isActiveWait ? '[当前活跃]' : ''} ${s.status === 'waiting' ? '[等待中]' : ''}(完整 token·如不确定请勿选择)`; + }); + log('select_conversation', { count: lines.length, titleNorm, planLabel }); + if (lines.length === 0) { + return makeContentText('当前没有活跃会话。请 init_conversation(传 cursorTitle=标签名, planLabel=计划名)。\n[transport: codeblock]'); + } + return makeContentText( + 'Active sessions (latest first):\n' + lines.join('\n') + + `\n⚠️ 必须传 cursorTitle=当前标签名;须选完整 ct_xxxxxxxx,禁止前缀泛搜。\n` + + `[transport: codeblock]\n本地回复面板: ${HUB.replace('127.0.0.1', '127.0.0.1')}/` + ); + } + + if (name === 'verify_karuo_ai_gate') { + const titleHit = resolveCursorTitle(args, args['pchat-token']); + const cursorTitle = titleHit.title || ''; + const checks = [ + { name: 'BOOTSTRAP.md', path: path.join(KARUO_AI_ROOT, 'BOOTSTRAP.md') }, + { name: 'AGENTS.md', path: path.join(KARUO_AI_ROOT, 'AGENTS.md') }, + { name: 'SKILL_REGISTRY.md', path: path.join(KARUO_AI_ROOT, 'SKILL_REGISTRY.md') }, + ]; + const fileLines = checks.map((c) => { + const ok = fs.existsSync(c.path); + return `${ok ? '✅' : '❌'} ${c.name} @ ${c.path}`; + }); + let pass = checks.every((c) => fs.existsSync(c.path)); + const lines = [ + '[KARUO_AI_GATE]', + `karuoAiRoot: ${KARUO_AI_ROOT}`, + '--- 卡若AI 真源 ---', + ...fileLines, + `cursorTitle: ${cursorTitle || '(未解析)'}(来源: ${titleHit.source})`, + ]; + if (args['pchat-token']) { + const resolved = await resolveThreadToken(args['pchat-token'], cursorTitle, titleHit.composerId); + if (!resolved.ok) { + pass = false; + lines.push('❌ 垂直绑定: 未对齐(见 BINDING_MISMATCH)'); + const prompt = await resolveOrPromptChoice(resolved, cursorTitle); + if (prompt) return prompt; + } else { + const v = await verifyVerticalBinding(resolved.token, cursorTitle); + if (v.ok) { + lines.push(`✅ 垂直绑定: ${bindingLockLine(v.token, v.title, v)}`); + } else { + pass = false; + lines.push(`❌ 垂直绑定: ${v.text}`); + } + } + } else { + lines.push('⚠️ 未传 pchat-token:仅校验卡若AI 文件;init/select 后请再验一次'); + } + lines.push(`[KARUO_AI_GATE] ${pass ? 'PASS' : 'FAIL'}`); + lines.push('[transport: codeblock]'); + log('verify_karuo_ai_gate', pass ? 'PASS' : 'FAIL', { cursorTitle, token: args['pchat-token'] }); + return makeContentText(lines.join('\n')); + } + + if (name === 'verify_binding') { + const titleHit = resolveCursorTitle(args, args['pchat-token']); + const cursorTitle = titleHit.title || args.cursorTitle || args.title || threadBoundTitle || ''; + const resolved = await resolveThreadToken(args['pchat-token'], cursorTitle); + const prompt = await resolveOrPromptChoice(resolved, cursorTitle); + if (prompt) return prompt; + const v = await verifyVerticalBinding(resolved.token, cursorTitle); + if (!v.ok) return makeContentText(v.text + '\n[transport: codeblock]'); + return makeContentText( + `✅ 垂直绑定 OK(校验不变)\n${bindingLockLine(v.token, v.title, v)}\n` + + `bindingKey: ${v.bindingKey || makeBindingKey(WORKSPACE, v.title, v.hostIp)}\n` + + `[transport: codeblock]` + ); + } + + if (name === 'wait_for_user_input') { + const titleHit = resolveCursorTitle(args, args['pchat-token']); + const cursorTitle = titleHit.title || args.title || threadBoundTitle || ''; + if (!cursorTitle || cursorTitle === '(未命名标签)') { + return makeContentText( + 'BINDING_MISSING_TITLE: wait 需要 Cursor 标签名(自动读 vscdb 失败时请传 title)。\n[transport: codeblock]' + ); + } + await syncTitleBinding(args['pchat-token'], cursorTitle, titleHit.composerId); + await maybeRenameBinding(args['pchat-token'], cursorTitle, titleHit.composerId); + const resolved = await resolveThreadToken(args['pchat-token'], cursorTitle, titleHit.composerId); + const prompt = await resolveOrPromptChoice(resolved, cursorTitle); + if (prompt) return prompt; + const token = resolved.token; + const bindCheck = await verifyVerticalBinding(token, cursorTitle); + if (!bindCheck.ok) return makeContentText(bindCheck.text); + const message = String(args.message || ''); + const registerWait = async () => { + await hubJson('/api/wait/register', { + method: 'POST', + body: JSON.stringify({ + token, + message, + prompt: args.prompt || '', + title: cursorTitle, + composerId: titleHit.composerId || '', + workspace: WORKSPACE, + pid: process.pid, + ...hubMeta(), + }), + }); + }; + try { + await registerWait(); + } catch (e) { + if (e.status === 403) { + return makeContentText( + `${e.data.error || 'BINDING_REJECTED'}: ${e.data.hint || e.message}\n` + + `请使用本标签绑定的 token: ${e.data.expected || threadBoundToken || '见面板'}` + ); + } + throw e; + } + log('wait begin', token); + activeWaitToken = token; + let reply; + let outerRetries = 0; + try { + while (outerRetries < 120) { + try { + await registerWait(); + reply = await hubPollWait(token, registerWait); + break; + } catch (e) { + const msg = String(e.message || e); + const retriable = /fetch failed|ECONNREFUSED|ECONNRESET|wait gone|unknown token|socket hang up/i.test(msg); + if (retriable && outerRetries < 119) { + outerRetries++; + log('wait outer retry', { token, attempt: outerRetries, msg: msg.slice(0, 80) }); + await new Promise((r) => setTimeout(r, 500)); + continue; + } + return makeContentText( + `HUB_CONNECTION_ERROR: ${msg}\n` + + `Hub ${HUB} 暂不可达。已重试 ${outerRetries} 次;请执行 sync-runtime.sh 或重开 MCP。\n` + + `[transport: codeblock]` + ); + } + } + } finally { + if (activeWaitToken === token) activeWaitToken = null; + } + log('wait reply', token, `len=${reply.length}`); + return { + content: [ + { type: 'text', text: reply }, + { type: 'text', text: `[pchat-token: ${token}]` }, + { type: 'text', text: bindingLockLine(token, cursorTitle, bindCheck) }, + { type: 'text', text: bindingFingerprint(token, cursorTitle, bindCheck) }, + ], + structuredContent: { + 'pchat-token': token, + reply, + cursorTitle: normTitle(cursorTitle), + hostIp: HOST_IP, + hub: HUB_LABEL, + bindingUnchanged: bindCheck.bindingUnchanged !== false, + }, + }; + } + + if (name === 'merge_conversation') { + if (threadBoundToken) { + return makeContentText(`MERGE_FORBIDDEN: 本线程已锁定 ${threadBoundToken},禁止 merge 串会话。`); + } + await hubJson('/api/merge', { + method: 'POST', + body: JSON.stringify({ source: args.source, target: args.target }), + }); + return makeContentText(`merged ${args.source} -> ${args.target}`); + } + + return makeContentText(`unknown tool: ${name}`); +} + +const rl = readline.createInterface({ input: process.stdin, terminal: false }); +rl.on('line', async (line) => { + if (!line.trim()) return; + let msg; + try { msg = JSON.parse(line); } catch { + log('bad json', line.slice(0, 200)); + return; + } + log('rx', { id: msg.id, method: msg.method }); + + try { + if (msg.method === 'initialize') { + return send({ + jsonrpc: '2.0', id: msg.id, + result: { + protocolVersion: '2024-11-05', + capabilities: { tools: { listChanged: false } }, + serverInfo: { name: 'local-persistent-chat', version: '1.6.30' }, + }, + }); + } + if (msg.method === 'notifications/initialized' || msg.method === 'initialized') return; + if (msg.method === 'tools/list') { + return send({ jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } }); + } + if (msg.method === 'tools/call') { + const result = await handleToolCall(msg.params?.name, msg.params?.arguments); + return send({ jsonrpc: '2.0', id: msg.id, result }); + } + if (msg.method === 'ping') { + return send({ jsonrpc: '2.0', id: msg.id, result: {} }); + } + if (msg.id != null) { + return send(makeError(msg.id, -32601, `method not found: ${msg.method}`)); + } + } catch (e) { + log('handler err', String(e.stack || e.message || e)); + if (msg.id != null) send(makeError(msg.id, -32000, String(e.message || e))); + } +}); + +/** Cursor 可能关闭 MCP stdin;挂起 poll 期间禁止 exit,否则 Hub 仍 waiting 但无 poller */ +let activeWaitToken = null; +process.stdin.on('end', () => { + log('stdin end', { activeWait: activeWaitToken }); + if (!activeWaitToken) setTimeout(() => process.exit(0), 200); +}); + +process.on('SIGTERM', () => process.exit(0)); +process.on('SIGINT', () => process.exit(0)); +process.on('uncaughtException', (e) => { + const msg = String(e.stack || e.message || e); + log('uncaught', msg); + if (/EPIPE|ERR_STREAM_DESTROYED/i.test(msg)) return; +}); +process.stdout.on('error', (e) => { + if (e && e.code === 'EPIPE') return; +}); diff --git a/web/panel.html b/web/panel.html new file mode 100755 index 0000000..65c5b22 --- /dev/null +++ b/web/panel.html @@ -0,0 +1,1743 @@ + + + + +卡若AI · Persistent Chat + + + + +
+ +
+

卡若AI · 持久对话

+
+ 连接中… +
+ +
+ + + +
+
+
+
+
+ +
+
+
还没开始?
+ 点 粘贴到 Cursor Chat(Agent 模式),或点左侧 + 新建任务

+ Agent 会 init_conversation 创建会话,再 wait_for_user_input 挂起;之后从本面板回复即可。 +
+
+
+
+ 未选择会话 +
+ +
+ + + + diff --git a/web/panel.html.local b/web/panel.html.local new file mode 100755 index 0000000..65c5b22 --- /dev/null +++ b/web/panel.html.local @@ -0,0 +1,1743 @@ + + + + +卡若AI · Persistent Chat + + + + +
+ +
+

卡若AI · 持久对话

+
+ 连接中… +
+ +
+ + + +
+
+
+
+
+ +
+
+
还没开始?
+ 点 粘贴到 Cursor Chat(Agent 模式),或点左侧 + 新建任务

+ Agent 会 init_conversation 创建会话,再 wait_for_user_input 挂起;之后从本面板回复即可。 +
+
+
+
+ 未选择会话 +
+ +
+ + + + diff --git a/web/panel.remote.html b/web/panel.remote.html new file mode 100644 index 0000000..48faf11 --- /dev/null +++ b/web/panel.remote.html @@ -0,0 +1,1744 @@ + + + + +卡若AI · Persistent Chat + + + + + +
+ +
+

卡若AI · 持久对话

+
+ 连接中… +
+ +
+ + + +
+
+
+
+
+ +
+
+
还没开始?
+ 点 粘贴到 Cursor Chat(Agent 模式),或点左侧 + 新建任务

+ Agent 会 init_conversation 创建会话,再 wait_for_user_input 挂起;之后从本面板回复即可。 +
+
+
+
+ 未选择会话 +
+ +
+ + + +