Fix cross-conversation reply auto-resume
This commit is contained in:
477
README.md
477
README.md
@@ -1,222 +1,318 @@
|
||||
# CC-Web
|
||||
|
||||
Claude Code / Codex 轻量级 Web 远程工具 — 在浏览器中与本机 CLI Agent 交互。
|
||||
Claude Code / Codex / Codex App 的 Web 远程协作工作台。
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
[English README](./README.en.md) | [更新日志](./CHANGELOG.md)
|
||||
|
||||
Vibe产物,readme比较絮叨,建议直接丢给CC,拷打一番就好。
|
||||
> 当前仓库已经不是原始 cc-web 的简单轻量封装版本。它在多轮真实使用中围绕
|
||||
> Codex App、MCP、跨对话协作、子代理可视化、移动端体验和工程化回归做了大量扩展。
|
||||
|
||||
## 一键部署:claude
|
||||
```
|
||||
https://github.com/ZgDaniel/cc-web 给我装!
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/747bca02-8861-446a-98fc-5f7417ceec4d" alt="截图1" width="20%" />
|
||||
<img src="https://github.com/user-attachments/assets/556ec006-aff2-4a7f-b826-5c98a2f33467" alt="截图2" width="20%" />
|
||||
<img src="https://github.com/user-attachments/assets/a9124f2c-b769-41c0-97f3-d69a6b1b9f80" alt="截图3" width="20%" />
|
||||
<img src="https://github.com/user-attachments/assets/0c4da44c-a426-483a-8cb0-9cc483fd5ecc" alt="截图4" width="20%" />
|
||||
</p>
|
||||
## 项目定位
|
||||
|
||||
CC-Web 现在更接近一个 **本地 Agent 编排台**:
|
||||
|
||||
- 浏览器里统一管理 Claude、Codex CLI、Codex App 三类 Agent 会话
|
||||
- 后端负责会话持久化、进程管理、Codex App app-server 接入、MCP 注入和通知
|
||||
- 前端负责多会话切换、项目分组、富文本/工具调用渲染、图片附件、跨对话状态和移动端交互
|
||||
- 内置 ccweb MCP,让一个对话可以创建、投递、等待和接收其他 ccweb 对话的结果
|
||||
|
||||
|
||||
## 相对原仓库的主要变化
|
||||
|
||||
| 方向 | 当前能力 |
|
||||
|------|----------|
|
||||
| 多 Agent | 支持 `claude`、`codex`、`codexapp` 三种会话,并按 Agent 隔离最近会话、模式和配置 |
|
||||
| Codex App | 接入 Codex app-server,支持流式消息、工具调用、审批、引导输入、Goal、MCP reload 和运行态恢复 |
|
||||
| ccweb MCP | 内置 `ccweb` MCP server,支持列会话、创建持久对话、跨对话发送、请求回复和等待回复查询 |
|
||||
| 原生子代理 | Codex App 原生 `spawn_agent` / `wait_agent` 等协作工具会在页面中合并展示为子代理状态卡片 |
|
||||
| 跨对话编排 | 子对话完成后可把结果写回来源对话;来源对话忙碌时进入等待队列,空闲后再投递 |
|
||||
| 输入增强 | `/` 命令、`@` 文件/Prompt、`$` Skill、MCP 候选项、Codex skill `agents/openai.yaml` 元数据展示 |
|
||||
| 会话体验 | 项目分组、项目折叠、搜索、置顶、快速在项目下新建会话、旧会话自动折叠 |
|
||||
| 富内容 | Markdown、代码高亮、代码复制/预览、图片上传/粘贴/预览、附件过期清理 |
|
||||
| 移动端 | 修复移动端会话切换、视口高度、触控复制、上传和通知体验 |
|
||||
| 稳定性 | 进程恢复、Codex App 状态落盘、输出分段、自动 compact、回归脚本覆盖主路径 |
|
||||
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **超轻量** — 后端性能占用少,前端通过 web 访问
|
||||
- **双 Agent 会话** — 新建会话时可选择 Claude 或 Codex,沿用相同的 Web 会话与后台任务模型
|
||||
- **工作目录更顺手** — 新会话弹窗会显示默认目录、最近目录快捷项,也能直接弹出目录选择器
|
||||
- **Agent 视图隔离** — 侧边栏切换 Claude / Codex 后,仅展示当前 Agent 的会话与最近记录,互不干扰
|
||||
- **独立 Agent 设置** — Claude 与 Codex 拥有各自的设置入口与默认行为,保持贴近各自原生 CLI 的使用方式
|
||||
- **多会话管理** — 创建、切换、重命名、删除会话,删除时同步清除本地 Claude 历史记录
|
||||
- **会话续接** — 基于 `--resume` 实现跨消息上下文保持,也可通过 SSH 使用 `tmux attach -t claude` 命令加入会话
|
||||
- **本地历史导入** — Claude 可导入 `~/.claude/projects/` 会话;Codex 可导入 `~/.codex/sessions/` rollout 历史
|
||||
- **后台任务** — 关闭浏览器后 Claude 进程继续运行,完成后推送通知
|
||||
- **多渠道通知** — 支持 PushPlus / Telegram / Server酱 / 飞书机器人 / QQ(Qmsg),Web UI 内可视化配置
|
||||
- **进程持久化** — detached 进程 + PID 文件,服务重启不丢失运行中的任务
|
||||
- **多 API 切换** — 可配置多个 API 方案,UI 中一键切换,即时生效
|
||||
- **密码认证** — 自动生成初始密码、首次登录强制改密、Web UI 修改密码
|
||||
- **隔离式回归脚本** — `npm run regression` 在临时目录中使用 mock Claude / Codex CLI 校验主路径,不污染真实数据
|
||||
### Agent 与会话
|
||||
|
||||
- **三类 Agent**:Claude、Codex CLI、Codex App
|
||||
- **独立会话空间**:切换 Agent 后只显示对应 Agent 的会话与最近记录
|
||||
- **权限模式**:支持 `default`、`plan`、`yolo`
|
||||
- **模型切换**:Claude 使用预设映射,Codex / Codex App 支持自由模型名
|
||||
- **历史导入**:支持导入 `~/.claude/projects/` 和 `~/.codex/sessions/` 中的本地历史
|
||||
- **后台任务**:浏览器关闭后子进程继续运行,重新打开后自动同步
|
||||
|
||||
### Codex App 集成
|
||||
|
||||
Codex App 模式用于贴近 Codex 官方 app-server 的能力,而不是复用旧 CLI 输出解析。
|
||||
|
||||
- 初始化后发送 `initialized` notification
|
||||
- best-effort 启用 `goals` feature,并探测 `collaborationMode/list`
|
||||
- 使用 `collaborationMode` 传递模型、推理强度和开发者指令
|
||||
- 支持 `/goal` 查看、设置、暂停、恢复和清除持久目标
|
||||
- 支持交互式审批和 `request_user_input` 引导输入
|
||||
- 支持运行中插入用户消息
|
||||
- 支持 `config/mcpServer/reload`,不用重启整个 ccweb 就能重载当前对话 MCP
|
||||
- 默认启用 Codex App worker 隔离,降低 app-server 异常对主进程的影响
|
||||
- 将 Codex App streaming state 写入 `sessions/{id}-run/codexapp-state.json`,服务重启后可恢复
|
||||
|
||||
### ccweb MCP 与跨对话协作
|
||||
|
||||
ccweb 会给 Codex / Codex App 会话注入内置 MCP 配置,让 Agent 可以调度同一个 Web 工作台里的其他对话。
|
||||
|
||||
| MCP 工具 | 用途 |
|
||||
|----------|------|
|
||||
| `ccweb_list_conversations` | 列出可投递的 ccweb 对话,只返回轻量元数据 |
|
||||
| `ccweb_create_conversation` | 创建新的 ccweb 持久对话,可指定 `cwd`、标题、模式和首条消息 |
|
||||
| `ccweb_send_message` | 给指定对话投递消息,目标对话显示“来自某对话”的气泡 |
|
||||
| `ccweb_request_reply` | 投递消息并等待目标对话本轮完成后返回结果 |
|
||||
| `ccweb_list_pending_replies` | 查看当前来源对话等待中的跨对话回复 |
|
||||
| `ccweb_get_pending_reply` | 按 `requestId` 读取等待回复的状态和正文 |
|
||||
|
||||
设计边界:
|
||||
|
||||
- **一次性并行研究** 优先使用 Codex App 原生子代理
|
||||
- **需要长期可追踪、可继续打开聊天的任务** 使用 `ccweb_create_conversation`
|
||||
- `ccweb_create_conversation` 默认继承来源对话 Agent;未显式传 `mode` 时默认 `yolo`
|
||||
- 跨对话返回内容会以只读展示消息写回来源对话,避免再次触发来源模型浪费 token
|
||||
|
||||
### 子代理状态展示
|
||||
|
||||
Codex App 原生协作工具会被转成页面上的子代理状态卡片:
|
||||
|
||||
- 合并展示 `spawn_agent` / `wait_agent` / `close_agent` 等工具调用
|
||||
- 显示子代理运行中、已返回、已关闭等状态
|
||||
- 支持手动关闭子代理
|
||||
- 子代理结果可合并进父消息工具调用结果,便于后续追踪
|
||||
|
||||
### 输入框与快捷能力
|
||||
|
||||
- `/`:本地 slash command、MCP 工具和 MCP server 候选
|
||||
- `@`:项目文件、目录、配置 Prompt 和 `~/.codex/prompts` Prompt
|
||||
- `$`:Codex Skill,包含项目级 skill、用户级 skill 和元数据
|
||||
- 支持 Codex skill 的 `agents/openai.yaml`:展示名称、描述、图标、默认提示词预览和 MCP 依赖
|
||||
- 发送后持久化 mention 元数据,气泡中可显示已引用的 Prompt / File / Skill
|
||||
- 支持笔记模式:先把内容记成可编辑气泡,稍后再发送给 Agent
|
||||
|
||||
### 前端体验
|
||||
|
||||
- 项目分组、折叠、搜索、置顶和项目内快速新建会话
|
||||
- 会话运行中、等待跨对话回复、未读状态等可视化提示
|
||||
- Markdown 渲染、代码块高亮、复制、预览
|
||||
- 图片附件上传、剪贴板粘贴、缩略图和放大预览
|
||||
- 附件大小限制 10MB,单条消息最多 4 张图片,默认 7 天过期
|
||||
- Agent 输出分隔线可显示时间,也可在设置中关闭
|
||||
- 每条助手气泡末尾提供“定位到本条最后一段”的按钮
|
||||
- 跨对话返回气泡支持折叠,并把折叠状态缓存到浏览器
|
||||
- 多套主题,包含亮色与暗色方案
|
||||
|
||||
|
||||
## 前提条件
|
||||
|
||||
- **Node.js** >= 18
|
||||
- **Claude Code CLI** 或 **Codex CLI** 已安装并配置
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
npm install -g @openai/codex
|
||||
```
|
||||
- Node.js >= 18
|
||||
- npm
|
||||
- 已安装并登录需要使用的 Agent CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
npm install -g @openai/codex
|
||||
```
|
||||
|
||||
> Codex App 相关能力依赖当前 Codex CLI / app-server 是否暴露对应协议能力。
|
||||
> 如果某个渠道没有 `spawn_agent`、`tool_search` 或 goals,ccweb 只能按运行时能力降级。
|
||||
|
||||
|
||||
## 快速开始
|
||||
|
||||
### Linux / macOS
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ZgDaniel/cc-web.git
|
||||
git clone <cc-web-repo-url>
|
||||
cd cc-web
|
||||
./start.sh
|
||||
```
|
||||
|
||||
`start.sh` 会检查 Node.js/npm 环境,自动创建 `.env`(如不存在),引导输入初始登录密码,自动安装 PM2(如未安装),安装项目依赖,并以 `ccweb` 为默认应用名启动或重启服务。
|
||||
`start.sh` 会执行以下动作:
|
||||
|
||||
如只想前台临时运行,也可以手动执行:
|
||||
- 拒绝 root 用户运行
|
||||
- 检查 Node.js >= 18 和 npm
|
||||
- 从 `.env.example` 创建 `.env`
|
||||
- 首次部署时引导设置登录密码
|
||||
- 检查 Claude / Codex CLI 是否可用
|
||||
- 自动安装 PM2
|
||||
- 使用 `npm ci` 或 `npm install` 安装依赖
|
||||
- 以 `ccweb` 为默认 PM2 应用名启动或重启服务
|
||||
|
||||
临时前台运行:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
|
||||
```text
|
||||
http://localhost:8002
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
```cmd
|
||||
git clone https://github.com/ZgDaniel/cc-web.git
|
||||
git clone <cc-web-repo-url>
|
||||
cd cc-web
|
||||
npm install
|
||||
copy .env.example .env & REM 可选
|
||||
copy .env.example .env
|
||||
node server.js
|
||||
```
|
||||
然后双击 `start.bat`,或在终端运行 `node server.js`。
|
||||
|
||||
---
|
||||
也可以双击 `start.bat`。
|
||||
|
||||
启动后访问 `http://localhost:8002`,输入密码即可使用。
|
||||
|
||||
## 配置
|
||||
|
||||
### 环境变量 (.env)
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 必填 | 默认值 | 说明 |
|
||||
|------|:---:|--------|------|
|
||||
| `CC_WEB_PASSWORD` | 否 | 自动生成 | Web 登录密码(首次启动自动迁移到 `config/auth.json`) |
|
||||
| `PORT` | 否 | `8002` | 服务监听端口 |
|
||||
| `CLAUDE_PATH` | 否 | `claude` | Claude CLI 可执行文件路径 |
|
||||
| `CODEX_PATH` | 否 | `codex` | Codex CLI 可执行文件路径 |
|
||||
| `CC_WEB_CONFIG_DIR` | 否 | `./config` | 配置目录覆写(主要供隔离测试使用) |
|
||||
| `CC_WEB_SESSIONS_DIR` | 否 | `./sessions` | 会话目录覆写(主要供隔离测试使用) |
|
||||
| `CC_WEB_LOGS_DIR` | 否 | `./logs` | 日志目录覆写(主要供隔离测试使用) |
|
||||
| `PUSHPLUS_TOKEN` | 否 | - | PushPlus Token(首次启动自动迁移到通知配置) |
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `PORT` | `8002` | HTTP / WebSocket 监听端口 |
|
||||
| `CC_WEB_PASSWORD` | 自动生成 | 首次启动登录密码;启动后会迁移到 `config/auth.json` |
|
||||
| `CLAUDE_PATH` | `claude` | Claude CLI 可执行文件路径 |
|
||||
| `CODEX_PATH` | `codex` | Codex CLI 可执行文件路径 |
|
||||
| `PUSHPLUS_TOKEN` | 空 | 首次启动可迁移到通知配置 |
|
||||
| `CC_WEB_CONFIG_DIR` | `./config` | 配置目录覆写,常用于测试隔离 |
|
||||
| `CC_WEB_SESSIONS_DIR` | `./sessions` | 会话目录覆写,常用于测试隔离 |
|
||||
| `CC_WEB_LOGS_DIR` | `./logs` | 日志目录覆写,常用于测试隔离 |
|
||||
| `CC_WEB_CODEX_APP_WORKER` | 开启 | 设为 `0` / `false` / `off` 可关闭 Codex App worker |
|
||||
| `CC_WEB_PROCESS_CLEAN_PATH` | 自动探测 | 清理旧 Codex app-server 进程时使用的匹配路径覆写 |
|
||||
|
||||
### 通知配置
|
||||
还有若干面向大历史、长输出和 Codex App 状态落盘的高级限制参数,例如:
|
||||
|
||||
点击侧边栏底部的 **⚙ 设置按钮**,在 Web UI 中可视化配置推送通知:
|
||||
- `CC_WEB_SESSION_PERSIST_MAX_MESSAGES`
|
||||
- `CC_WEB_SESSION_MESSAGE_CONTENT_MAX_CHARS`
|
||||
- `CC_WEB_SESSION_MAX_TOOL_CALLS_PER_MESSAGE`
|
||||
- `CC_WEB_CODEX_APP_STATE_MAX_BYTES`
|
||||
- `CC_WEB_CODEX_APP_RUNTIME_MAX_TOOL_CALLS`
|
||||
- `CC_WEB_CROSS_CONVERSATION_MAX_CONTENT_CHARS`
|
||||
- `CC_WEB_MCP_CREATE_CONVERSATION_MAX_HOP_COUNT`
|
||||
|
||||
| 通知方式 | 所需配置 | 获取方式 |
|
||||
|---------|---------|---------|
|
||||
| **PushPlus**(微信推送) | Token | [pushplus.plus](https://www.pushplus.plus/) 注册获取 |
|
||||
| **Telegram** | Bot Token + Chat ID | [@BotFather](https://t.me/BotFather) 创建机器人 |
|
||||
| **Server酱** | SendKey | [sct.ftqq.com](https://sct.ftqq.com/) 注册获取 |
|
||||
| **飞书机器人** | Webhook URL | 飞书群 → 设置 → 群机器人 → 添加自定义机器人 |
|
||||
| **QQ(Qmsg)** | Qmsg Key | [qmsg.zendee.cn](https://qmsg.zendee.cn/) 登录后获取,需添加接收 QQ 号 |
|
||||
默认值已按当前项目的长会话场景设置,通常不需要调整。
|
||||
|
||||
配置保存在 `config/notify.json`,Token 在 UI 中脱敏显示(仅显示前4后4位)。
|
||||
### 运行态文件
|
||||
|
||||
### 密码管理
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `config/auth.json` | 登录密码配置,运行时生成 |
|
||||
| `config/notify.json` | 通知渠道配置,运行时生成 |
|
||||
| `config/codex.json` | Codex 默认模型等配置,运行时生成 |
|
||||
| `config/cross-conversation-replies.json` | 跨对话等待回复状态 |
|
||||
| `sessions/*.json` | ccweb 会话历史 |
|
||||
| `sessions/{id}-run/` | 单次运行输出、PID、Codex App 状态 |
|
||||
| `sessions/_attachments/` | 图片附件与元数据 |
|
||||
| `logs/process.log` | 进程生命周期日志 |
|
||||
|
||||
密码存储在 `config/auth.json`,支持自动生成与 Web UI 修改:
|
||||
不要提交真实 token、API Key、`.env`、真实会话数据和日志。
|
||||
|
||||
|
||||
## 通知配置
|
||||
|
||||
点击侧边栏底部设置入口,可以配置:
|
||||
|
||||
| 通知方式 | 所需配置 |
|
||||
|----------|----------|
|
||||
| PushPlus | Token |
|
||||
| Telegram | Bot Token + Chat ID |
|
||||
| Server 酱 | SendKey |
|
||||
| 飞书机器人 | Webhook URL |
|
||||
| QQ / Qmsg | Qmsg Key |
|
||||
|
||||
Token 在 UI 中脱敏显示,配置写入 `config/notify.json`。
|
||||
|
||||
- **首次启动**(无 `.env` 密码、无 `auth.json`):自动生成 12 位随机密码,打印到控制台,首次登录强制修改
|
||||
- **从 `.env` 迁移**:如已在 `.env` 设置 `CC_WEB_PASSWORD`,启动时自动迁移到 `auth.json`,无需改密
|
||||
- **Web UI 修改**:设置面板 → 修改密码(需输入当前密码)
|
||||
- **密码要求**:≥ 8 位,包含大写/小写/数字/特殊字符中的至少 2 种
|
||||
- **改密后**:所有已登录会话失效,需重新认证
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
```text
|
||||
cc-web/
|
||||
├── server.js # Node.js 后端(HTTP + WebSocket + 进程管理 + 通知)
|
||||
├── server.js # HTTP / WebSocket / 会话 / 进程 / MCP / Codex App 编排
|
||||
├── lib/
|
||||
│ ├── agent-runtime.js # Claude / Codex 运行时适配层
|
||||
│ └── codex-rollouts.js # Codex rollout 历史解析
|
||||
│ ├── agent-runtime.js # Claude / Codex CLI spawn spec 与事件解析
|
||||
│ ├── codex-app-runtime.js # Codex App notification / tool / stream 归一化
|
||||
│ ├── codex-app-server-client.js # Codex app-server JSON-RPC 客户端
|
||||
│ ├── codex-app-worker.js # Codex App worker 进程入口
|
||||
│ ├── codex-app-worker-client.js # 主进程到 worker 的客户端
|
||||
│ ├── ccweb-mcp-server.js # 内置 ccweb MCP stdio server
|
||||
│ └── codex-rollouts.js # Codex 本地 rollout 历史解析
|
||||
├── public/
|
||||
│ ├── index.html # 页面结构
|
||||
│ ├── app.js # 前端逻辑(WebSocket 通信、UI 交互)
|
||||
│ ├── style.css # 样式(和风暖色调主题)
|
||||
│ └── sw.js # Service Worker(移动端推送通知)
|
||||
├── config/
|
||||
│ ├── codex.json # Codex 独立配置(运行时生成)
|
||||
│ ├── notify.json # 通知渠道配置(运行时生成)
|
||||
│ └── auth.json # 密码配置(运行时生成)
|
||||
├── sessions/ # 对话历史 JSON 文件(运行时生成)
|
||||
├── logs/ # 进程生命周期日志(运行时生成)
|
||||
│ ├── index.html # 页面结构
|
||||
│ ├── app.js # 前端状态、WebSocket、会话列表、消息渲染
|
||||
│ ├── style.css # 主题、布局、移动端与工具调用样式
|
||||
│ └── sw.js # Service Worker / 浏览器通知
|
||||
├── scripts/
|
||||
│ ├── regression.js # 隔离式回归脚本
|
||||
│ ├── mock-claude.js # 回归用 mock Claude CLI
|
||||
│ └── mock-codex.js # 回归用 mock Codex CLI
|
||||
├── .env.example # 环境变量模板
|
||||
├── start.sh # Linux / macOS PM2 一键启动脚本
|
||||
├── start.bat # Windows 一键启动脚本
|
||||
├── .gitignore
|
||||
├── package.json
|
||||
└── README.md
|
||||
│ ├── regression.js # 隔离式回归脚本
|
||||
│ ├── mock-claude.js # 回归用 mock Claude CLI
|
||||
│ ├── mock-codex.js # 回归用 mock Codex CLI
|
||||
│ └── mock-codex-app-server.js # 回归用 mock Codex app-server
|
||||
├── config/ # 运行态配置目录
|
||||
├── sessions/ # 运行态会话目录
|
||||
├── logs/ # 运行态日志目录
|
||||
├── AGENTS.md # 本项目 Agent 工作约定
|
||||
├── .cbmignore # codebase-memory-mcp 索引忽略规则
|
||||
├── .env.example
|
||||
├── start.sh
|
||||
├── start.bat
|
||||
└── package.json
|
||||
```
|
||||
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 进程模型
|
||||
|
||||
```
|
||||
浏览器 ←WebSocket→ Node.js (server.js) ←文件I/O→ Claude / Codex CLI (detached)
|
||||
```text
|
||||
[Browser]
|
||||
│ WebSocket / HTTP
|
||||
▼
|
||||
[server.js]
|
||||
├── Claude / Codex CLI detached process
|
||||
├── Codex App app-server / worker
|
||||
├── ccweb internal MCP API
|
||||
├── sessions / run state / attachments
|
||||
└── notifications / auth / process log
|
||||
```
|
||||
|
||||
- 每条用户消息会根据当前会话 Agent,spawn Claude 或 Codex 子进程
|
||||
- 进程使用 `detached: true` + `proc.unref()`,独立于 Node.js 生命周期
|
||||
- stdin/stdout/stderr 通过文件传递(`sessions/{id}-run/`),不使用 pipe
|
||||
- PID 持久化到文件,服务重启后自动恢复(`recoverProcesses()`)
|
||||
- 使用 `FileTailer` 实时监听输出文件变化,流式推送给前端
|
||||
- Claude / Codex 的 spawn spec 与事件解析分别由 `lib/agent-runtime.js` 管理
|
||||
关键设计:
|
||||
|
||||
### 后台任务流程
|
||||
- 普通 Claude / Codex CLI 会话通过 detached 子进程运行
|
||||
- Codex App 会话走 app-server JSON-RPC,并可通过 worker 隔离
|
||||
- 会话 JSON 是前端恢复和历史展示的主要数据源
|
||||
- 运行中输出会同步写入 `{sessionId}-run/`,避免异常重启后完全丢失
|
||||
- 内部 MCP token 只在本进程与子进程环境中传递,日志会做脱敏
|
||||
- 跨对话消息不会包含对话全文摘要,避免上下文过大
|
||||
|
||||
1. 用户发送消息 → spawn Claude 进程
|
||||
2. 用户关闭浏览器 → 进程继续运行(detached)
|
||||
3. 进程完成 → PID 监控检测到退出
|
||||
4. 发送推送通知(PushPlus/Telegram/...)
|
||||
5. 用户重新打开 → 自动同步完成的回复
|
||||
|
||||
### 进程日志
|
||||
|
||||
日志文件 `logs/process.log`(JSONL 格式,自动轮转 2MB),记录完整的进程生命周期:
|
||||
|
||||
| 事件 | 说明 |
|
||||
|------|------|
|
||||
| `process_spawn` | 进程创建(PID、模式、模型) |
|
||||
| `process_complete` | 进程完成(退出码、耗时、费用) |
|
||||
| `ws_connect` / `ws_disconnect` | 客户端连接/断开 |
|
||||
| `ws_resume_attach` | 客户端重连并挂载到运行中的进程 |
|
||||
| `recovery_alive` / `recovery_dead` | 服务重启时恢复进程 |
|
||||
| `heartbeat` | 每 60 秒活跃进程状态快照 |
|
||||
|
||||
查看日志:
|
||||
```bash
|
||||
tail -f logs/process.log | jq .
|
||||
```
|
||||
|
||||
## 生产部署
|
||||
|
||||
### PM2 一键启动
|
||||
|
||||
推荐在 Linux 服务器使用非 root 用户部署:
|
||||
## 回归测试
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ZgDaniel/cc-web.git
|
||||
cd cc-web
|
||||
./start.sh
|
||||
npm run regression
|
||||
```
|
||||
|
||||
脚本默认执行以下动作:
|
||||
回归脚本会在临时目录中启动 mock Claude、mock Codex 和 mock Codex app-server,覆盖:
|
||||
|
||||
- 检查 Node.js >= 18 与 npm
|
||||
- 自动创建 `.env`(如不存在),并补齐默认 `PORT=8002`
|
||||
- 首次部署时引导输入 `CC_WEB_PASSWORD`,写入 `.env` 后由服务迁移到 `config/auth.json`
|
||||
- 如果已存在 `config/auth.json`,跳过初始密码设置,避免覆盖已修改的密码
|
||||
- 检查 Claude / Codex CLI 是否可用;未安装时仅提示,不阻塞 Web 服务启动
|
||||
- 检查 PM2,未安装时自动执行 `npm install -g pm2`
|
||||
- 使用 `npm ci` 安装项目依赖(无 `package-lock.json` 时退回 `npm install`)
|
||||
- 使用 PM2 启动或重启 `server.js`,默认应用名为 `ccweb`
|
||||
- 执行 `pm2 save` 保存当前进程快照
|
||||
- 登录、会话创建、模式切换
|
||||
- Claude / Codex 消息与图片附件
|
||||
- Codex App streaming、tool call、审批、引导输入、Goal
|
||||
- Composer 的 `/`、`@`、`$` 候选与 mention 持久化
|
||||
- ccweb MCP 的创建对话、跨对话投递、等待回复和队列
|
||||
- Codex App worker 恢复与异常状态保护
|
||||
|
||||
常用命令:
|
||||
|
||||
## 运维
|
||||
|
||||
### PM2
|
||||
|
||||
```bash
|
||||
pm2 status ccweb
|
||||
@@ -224,25 +320,27 @@ pm2 logs ccweb
|
||||
pm2 restart ccweb --update-env
|
||||
```
|
||||
|
||||
如果需要服务器重启后自动恢复 PM2 进程,请执行:
|
||||
如果需要开机自启:
|
||||
|
||||
```bash
|
||||
pm2 startup
|
||||
```
|
||||
|
||||
然后按命令输出的提示执行生成的 `sudo ...` 命令,最后再次执行:
|
||||
|
||||
```bash
|
||||
pm2 save
|
||||
```
|
||||
|
||||
### systemd 服务
|
||||
### 重启前检查
|
||||
|
||||
创建 `/etc/systemd/system/cc-web.service`:
|
||||
修改 ccweb 项目后如果需要重启服务,应先确认当前会话列表中是否还有其他 `running` 对话。
|
||||
|
||||
- 除当前维护对话外没有其他运行中对话:可以重启
|
||||
- 仍有其他运行中对话:暂缓重启,避免打断正在执行的 Agent
|
||||
|
||||
### systemd
|
||||
|
||||
如果不用 PM2,可以使用 systemd。重点是只杀 Node.js 主进程,不主动杀 Claude / Codex 子进程。
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=CC-Web - Claude Code Web Chat UI
|
||||
Description=CC-Web
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
@@ -252,71 +350,34 @@ WorkingDirectory=/path/to/cc-web
|
||||
ExecStart=/usr/bin/node server.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# 重要:只杀 Node.js 进程,不杀 Claude 子进程
|
||||
KillMode=process
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
> **`KillMode=process` 非常重要**:确保 systemd 重启服务时只杀 Node.js 进程,Claude 子进程继续运行,服务恢复后自动重新挂载。
|
||||
|
||||
```bash
|
||||
sudo systemctl enable cc-web
|
||||
sudo systemctl start cc-web
|
||||
```
|
||||
## 开发约定
|
||||
|
||||
### Nginx 反向代理
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name your-domain.com;
|
||||
|
||||
ssl_certificate /path/to/fullchain.pem;
|
||||
ssl_certificate_key /path/to/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8002;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket 支持
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
||||
# 长连接超时(Claude 任务可能运行较久)
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Windows 部署
|
||||
|
||||
适用于在个人电脑上运行 CC-Web,通过手机远程控制 Claude Code。
|
||||
|
||||
**启动方式**:双击 `start.bat`,或在终端运行:
|
||||
```cmd
|
||||
cd cc-web
|
||||
npm install
|
||||
node server.js
|
||||
```
|
||||
|
||||
**局域网访问**(手机和电脑在同一 WiFi):
|
||||
- 直接访问 `http://电脑局域网IP:8002`
|
||||
|
||||
**远程访问**(外出时用手机控制家里电脑):
|
||||
- 推荐使用 [Tailscale](https://tailscale.com/) — 电脑和手机各安装一个,自动组网,免费够用
|
||||
- 或使用 [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)(需域名)
|
||||
- 本项目代码理解优先使用 `codebase-memory-mcp`,项目名为 `home-cc-web`
|
||||
- `graphify` 已弃用,不再作为主检索路径
|
||||
- 涉及 Codex App 协议时优先对齐上游 app-server 参数形状
|
||||
- 自定义跨会话能力走 `mcp_servers.ccweb`,不要优先回退到 dynamicTools
|
||||
- 前端改动要同时考虑桌面端、移动端和深色主题
|
||||
- 代码改动后至少运行 `npm run regression`
|
||||
|
||||
|
||||
## Agent必读
|
||||
## 安全注意
|
||||
|
||||
- 不要用 root 用户部署
|
||||
- 不要提交 `.env`、真实 API Key、真实通知 token、真实生产日志
|
||||
- 内部 MCP token 不应暴露到前端或日志
|
||||
- 图片附件会落盘到 `sessions/_attachments/`,默认 7 天过期
|
||||
- 对公网开放时建议放到 HTTPS 反向代理后,并使用强密码
|
||||
|
||||
如果你是claude/codex,正在帮助用户部署本项目,需要提醒用户使用非root用户部署
|
||||
|
||||
## 其他
|
||||
本项目得到[@carrotgithub](https://github.com/carrotgithub/)、[@YoungHong1992](https://github.com/YoungHong1992/) 的悉心指导;收获[@123aliez](https://github.com/123aliez/) 的算力支持; @小苏肉 的辛勤测试
|
||||
|
||||
亦在 [Linux.Do](https://linux.do/) 社区获益良多,欢迎加入
|
||||
本项目源自 cc-web,并在实际使用中围绕 Codex App 与 ccweb MCP 做了重构和扩展。
|
||||
|
||||
感谢原项目作者、社区反馈和测试同学对这个工具方向的持续推动。
|
||||
|
||||
@@ -62,7 +62,7 @@ const TOOLS = [
|
||||
},
|
||||
requestReply: {
|
||||
type: 'boolean',
|
||||
description: '可选。若为 true,会在新对话完成本轮输出后把回复作为已处理的只读消息写回来源对话,不会再次触发来源对话运行。默认 false。',
|
||||
description: '可选。若为 true,会在新对话完成本轮输出后把回复写回来源对话,并继续触发来源对话运行。默认 false。',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -119,7 +119,7 @@ const TOOLS = [
|
||||
},
|
||||
{
|
||||
name: 'ccweb_request_reply',
|
||||
description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后把回复作为已处理的只读消息写回当前对话;写回不会再次触发当前对话运行。',
|
||||
description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后把回复写回当前对话,然后继续触发当前对话运行。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
@@ -218,7 +218,7 @@
|
||||
})();
|
||||
const pendingNotesByTarget = new Map();
|
||||
const userMessageIndex = new Map();
|
||||
const expandedOldSessionAgents = new Set();
|
||||
const expandedOldSessionGroups = new Set();
|
||||
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
||||
|
||||
// --- DOM ---
|
||||
@@ -2722,11 +2722,15 @@
|
||||
return Number.isFinite(updatedMs) && updatedMs > 0 && nowMs - updatedMs > OLD_SESSION_COLLAPSE_MS;
|
||||
}
|
||||
|
||||
function splitCollapsedOldSessions(regularSessions, pinnedCount) {
|
||||
if (expandedOldSessionAgents.has(currentAgent)) {
|
||||
return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] };
|
||||
}
|
||||
function getProjectOldSessionCollapseKey(group) {
|
||||
return `project:${getProjectCollapseKey(group)}`;
|
||||
}
|
||||
|
||||
function getUngroupedOldSessionCollapseKey() {
|
||||
return `${normalizeAgent(currentAgent)}:ungrouped`;
|
||||
}
|
||||
|
||||
function splitCollapsedOldSessions(regularSessions, pinnedCount, getCollapseKey = () => '') {
|
||||
const totalCount = pinnedCount + regularSessions.length;
|
||||
if (totalCount <= OLD_SESSION_COLLAPSE_VISIBLE_LIMIT) {
|
||||
return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] };
|
||||
@@ -2738,8 +2742,10 @@
|
||||
const hiddenOldSessions = [];
|
||||
|
||||
regularSessions.forEach((session, index) => {
|
||||
const collapseKey = getCollapseKey(session);
|
||||
const isExpanded = collapseKey && expandedOldSessionGroups.has(collapseKey);
|
||||
const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread || session.waitingOnChildren;
|
||||
const canCollapse = index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs);
|
||||
const canCollapse = !isExpanded && index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs);
|
||||
if (canCollapse && !shouldKeepVisible) {
|
||||
hiddenOldSessions.push(session);
|
||||
} else {
|
||||
@@ -2750,17 +2756,17 @@
|
||||
return { visibleRegularSessions, hiddenOldSessions };
|
||||
}
|
||||
|
||||
function createOldSessionLoadMoreButton(hiddenCount) {
|
||||
function createOldSessionLoadMoreButton(hiddenCount, collapseKey, projectName = '') {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'session-list-load-more';
|
||||
button.setAttribute('aria-label', `加载更多 ${hiddenCount} 条 7 天前会话`);
|
||||
button.setAttribute('aria-label', `加载更多${projectName ? ` ${projectName}` : ''} ${hiddenCount} 条 7 天前会话`);
|
||||
button.innerHTML = `
|
||||
<span class="session-list-load-more-title">加载更多</span>
|
||||
<span class="session-list-load-more-meta">${hiddenCount} 条 7 天前会话</span>
|
||||
`;
|
||||
button.addEventListener('click', () => {
|
||||
expandedOldSessionAgents.add(currentAgent);
|
||||
if (collapseKey) expandedOldSessionGroups.add(collapseKey);
|
||||
renderSessionList();
|
||||
});
|
||||
return button;
|
||||
@@ -5920,9 +5926,34 @@
|
||||
}
|
||||
|
||||
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
|
||||
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions);
|
||||
const oldSessionCollapseKeysBySessionId = new Map();
|
||||
projectGroups.forEach((group) => {
|
||||
const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group);
|
||||
group.sessions.forEach((session) => {
|
||||
oldSessionCollapseKeysBySessionId.set(session.id, oldSessionCollapseKey);
|
||||
});
|
||||
});
|
||||
ungroupedSessions.forEach((session) => {
|
||||
oldSessionCollapseKeysBySessionId.set(session.id, getUngroupedOldSessionCollapseKey());
|
||||
});
|
||||
const { visibleRegularSessions, hiddenOldSessions } = isSearchingSessions
|
||||
? { visibleRegularSessions: regularSessions, hiddenOldSessions: [] }
|
||||
: splitCollapsedOldSessions(regularSessions, pinnedSessions.length);
|
||||
: splitCollapsedOldSessions(
|
||||
regularSessions,
|
||||
pinnedSessions.length,
|
||||
(session) => oldSessionCollapseKeysBySessionId.get(session.id) || ''
|
||||
);
|
||||
const hiddenOldSessionCountsByKey = new Map();
|
||||
hiddenOldSessions.forEach((session) => {
|
||||
const oldSessionCollapseKey = oldSessionCollapseKeysBySessionId.get(session.id);
|
||||
if (!oldSessionCollapseKey) return;
|
||||
hiddenOldSessionCountsByKey.set(
|
||||
oldSessionCollapseKey,
|
||||
(hiddenOldSessionCountsByKey.get(oldSessionCollapseKey) || 0) + 1
|
||||
);
|
||||
});
|
||||
const visibleRegularSessionIds = new Set(visibleRegularSessions.map((session) => session.id));
|
||||
if (pinnedSessions.length > 0) {
|
||||
const pinnedGroupEl = document.createElement('section');
|
||||
pinnedGroupEl.className = 'session-project-group session-pinned-group';
|
||||
@@ -5941,9 +5972,11 @@
|
||||
sessionList.appendChild(pinnedGroupEl);
|
||||
}
|
||||
|
||||
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleRegularSessions);
|
||||
projectGroups.forEach((group, groupIndex) => {
|
||||
const groupKey = getProjectCollapseKey(group);
|
||||
const oldSessionCollapseKey = getProjectOldSessionCollapseKey(group);
|
||||
const visibleGroupSessions = group.sessions.filter((session) => visibleRegularSessionIds.has(session.id));
|
||||
const hiddenGroupOldSessionCount = hiddenOldSessionCountsByKey.get(oldSessionCollapseKey) || 0;
|
||||
const isCollapsed = !isSearchingSessions && collapsedProjectKeys.has(groupKey);
|
||||
const hasActiveSession = group.sessions.some((session) => session.id === currentSessionId);
|
||||
const hasUnreadSession = group.sessions.some((session) => session.hasUnread);
|
||||
@@ -5972,9 +6005,12 @@
|
||||
groupBody.id = groupBodyId;
|
||||
groupBody.className = 'session-project-sessions';
|
||||
groupBody.hidden = isCollapsed;
|
||||
for (const s of group.sessions) {
|
||||
for (const s of visibleGroupSessions) {
|
||||
groupBody.appendChild(createSessionListItem(s));
|
||||
}
|
||||
if (hiddenGroupOldSessionCount > 0) {
|
||||
groupBody.appendChild(createOldSessionLoadMoreButton(hiddenGroupOldSessionCount, oldSessionCollapseKey, group.name));
|
||||
}
|
||||
groupEl.appendChild(groupBody);
|
||||
|
||||
header.querySelector('.session-project-toggle').addEventListener('click', () => {
|
||||
@@ -5989,12 +6025,16 @@
|
||||
sessionList.appendChild(groupEl);
|
||||
});
|
||||
|
||||
for (const s of ungroupedSessions) {
|
||||
const ungroupedCollapseKey = getUngroupedOldSessionCollapseKey();
|
||||
const visibleUngroupedSessions = ungroupedSessions.filter((session) => visibleRegularSessionIds.has(session.id));
|
||||
const hiddenUngroupedOldSessionCount = hiddenOldSessionCountsByKey.get(ungroupedCollapseKey) || 0;
|
||||
|
||||
for (const s of visibleUngroupedSessions) {
|
||||
sessionList.appendChild(createSessionListItem(s));
|
||||
}
|
||||
|
||||
if (hiddenOldSessions.length > 0) {
|
||||
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenOldSessions.length));
|
||||
if (hiddenUngroupedOldSessionCount > 0) {
|
||||
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenUngroupedOldSessionCount, ungroupedCollapseKey));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -286,6 +286,10 @@ function nextMessage(messages, ws, predicate, timeoutMs = 15000) {
|
||||
});
|
||||
}
|
||||
|
||||
function isSessionCompletionMessage(msg, sessionId) {
|
||||
return (msg?.type === 'done' || msg?.type === 'background_done') && msg.sessionId === sessionId;
|
||||
}
|
||||
|
||||
function createFakeClaudeHistory(homeDir) {
|
||||
const projectDir = path.join(homeDir, '.claude', 'projects', 'tmp-project');
|
||||
mkdirp(projectDir);
|
||||
@@ -854,6 +858,7 @@ async function main() {
|
||||
assert(mcpCreateReply.body.mode === 'yolo', 'MCP create requestReply should default to yolo when mode is omitted');
|
||||
assert(mcpCreateReply.body.cwd === mcpReplyCreateCwd, 'MCP create conversation should use an explicit absolute cwd');
|
||||
assert(mcpCreateReply.body.requestId && mcpCreateReply.body.replyStatus === 'waiting', 'MCP create requestReply should return a waiting request id');
|
||||
assert(mcpCreateReply.body.replyDelivery === 'auto_run' && mcpCreateReply.body.sourceAutoRun === true, 'MCP create requestReply should declare source auto-run delivery');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreateReply.body.conversationId);
|
||||
await waitForJsonCondition(path.join(sessionsDir, `${codexSession.sessionId}.json`), (session) => (
|
||||
Array.isArray(session.messages) &&
|
||||
@@ -862,14 +867,19 @@ async function main() {
|
||||
message.crossConversation?.processed === true
|
||||
))
|
||||
));
|
||||
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
|
||||
const storedMcpCreateReply = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreateReply.body.conversationId}.json`), 'utf8'));
|
||||
const storedMcpCreateSource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
assert(storedMcpCreateReply.messages.some((message) => message.crossConversation?.replyRequestId === mcpCreateReply.body.requestId), 'MCP create requestReply should persist waiting metadata on the new conversation');
|
||||
assert(storedMcpCreateSource.messages.some((message) => (
|
||||
message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId &&
|
||||
message.crossConversation?.processed === true &&
|
||||
message.crossConversation?.autoRun === false
|
||||
)), 'MCP create requestReply should send a processed display-only reply back to source');
|
||||
const storedMcpCreateReplyIndex = storedMcpCreateSource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === mcpCreateReply.body.requestId);
|
||||
assert(storedMcpCreateReplyIndex >= 0, 'MCP create requestReply should send a processed display-only reply back to source');
|
||||
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.processed === true, 'MCP create requestReply should mark the returned message processed');
|
||||
assert(storedMcpCreateSource.messages[storedMcpCreateReplyIndex].crossConversation?.autoRun === true, 'MCP create requestReply should mark the returned message as auto-run');
|
||||
assert(storedMcpCreateSource.messages.slice(storedMcpCreateReplyIndex + 1).some((message) => (
|
||||
message.role === 'assistant' &&
|
||||
/mcp create request reply/.test(String(message.content || '')) &&
|
||||
/子对话/.test(String(message.content || ''))
|
||||
)), 'MCP create requestReply should continue the source session after the child reply');
|
||||
|
||||
const crossTargetCwd = path.join(tempRoot, 'codex-mcp-cross-target');
|
||||
mkdirp(crossTargetCwd);
|
||||
@@ -934,7 +944,7 @@ async function main() {
|
||||
});
|
||||
assert(requestReply.status === 200 && requestReply.body?.ok, `MCP request reply should succeed: ${JSON.stringify(requestReply.body)}`);
|
||||
assert(requestReply.body.requestId && requestReply.body.status === 'waiting', 'MCP request reply should return a waiting request id');
|
||||
assert(requestReply.body.replyDelivery === 'display_only' && requestReply.body.sourceAutoRun === false, 'MCP request reply should declare display-only delivery without source auto-run');
|
||||
assert(requestReply.body.replyDelivery === 'auto_run' && requestReply.body.sourceAutoRun === true, 'MCP request reply should declare source auto-run delivery');
|
||||
const requestReplyTargetBubble = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_message' &&
|
||||
msg.sessionId === crossReplyTargetSession.sessionId &&
|
||||
@@ -951,6 +961,7 @@ async function main() {
|
||||
message.crossConversation?.processed === true
|
||||
))
|
||||
));
|
||||
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, codexSession.sessionId));
|
||||
|
||||
const storedReplyTarget = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${crossReplyTargetSession.sessionId}.json`), 'utf8'));
|
||||
const storedReplySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexSession.sessionId}.json`), 'utf8'));
|
||||
@@ -963,15 +974,16 @@ async function main() {
|
||||
assert(storedReplyMessage.role === 'assistant', 'Returned cross message should be persisted as display-only assistant content');
|
||||
assert(storedReplyMessage.crossConversation.reply === true, 'Returned cross message should be marked as a reply');
|
||||
assert(storedReplyMessage.crossConversation.processed === true, 'Returned cross message should persist a processed marker');
|
||||
assert(storedReplyMessage.crossConversation.autoRun === false, 'Returned cross message should not auto-run the source session again');
|
||||
assert(storedReplyMessage.crossConversation.autoRun === true, 'Returned cross message should mark source auto-run');
|
||||
assert(storedReplyMessage.ccwebDisplayOnly === true, 'Returned cross message should be marked display-only');
|
||||
assert(/线程「/.test(storedReplyMessage.content || '') && /已返回消息/.test(storedReplyMessage.content || ''), 'Returned cross message should include reply heading');
|
||||
assert(/Codex mock handled/.test(storedReplyMessage.content || ''), 'Returned cross message should include target assistant output');
|
||||
assert(!storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
|
||||
assert(storedReplySource.messages.slice(storedReplyMessageIndex + 1).some((message) => (
|
||||
message.role === 'assistant' &&
|
||||
/Codex mock handled/.test(String(message.content || '')) &&
|
||||
/已返回消息/.test(String(message.content || ''))
|
||||
)), 'Returned cross message should not trigger the source session to run again');
|
||||
/cross reply requested/.test(String(message.content || '')) &&
|
||||
/子对话/.test(String(message.content || ''))
|
||||
)), 'Returned cross message should trigger the source session to run again');
|
||||
|
||||
const busySourceCwd = path.join(tempRoot, 'codex-mcp-busy-source');
|
||||
mkdirp(busySourceCwd);
|
||||
@@ -995,6 +1007,7 @@ async function main() {
|
||||
});
|
||||
assert(busyRequestReply.status === 200 && busyRequestReply.body?.ok, `MCP busy source request reply should succeed: ${JSON.stringify(busyRequestReply.body)}`);
|
||||
assert(busyRequestReply.body.requestId && busyRequestReply.body.status === 'waiting', 'Busy source request reply should return a waiting request id');
|
||||
assert(busyRequestReply.body.replyDelivery === 'auto_run' && busyRequestReply.body.sourceAutoRun === true, 'Busy source request reply should declare source auto-run delivery');
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === busyReplyTargetSession.sessionId);
|
||||
await waitForJsonCondition(path.join(configDir, 'cross-conversation-replies.json'), (state) => (
|
||||
Array.isArray(state.replies) &&
|
||||
@@ -1038,7 +1051,7 @@ async function main() {
|
||||
assert(busySourceSummary?.status === 'running', 'MCP list should still mark the busy source as running before it completes');
|
||||
assert(busySourceSummary?.waitingOnChildren === true && busySourceSummary?.readyReplyCount === 1, 'MCP list should expose queued child replies on the source conversation');
|
||||
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === busySourceSession.sessionId, 8000);
|
||||
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
|
||||
await waitForJsonCondition(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), (session) => (
|
||||
Array.isArray(session.messages) &&
|
||||
session.messages.some((message) => (
|
||||
@@ -1051,6 +1064,14 @@ async function main() {
|
||||
const busyReplyIndex = storedBusySource.messages.findIndex((message) => message.crossConversation?.replyToRequestId === busyRequestReply.body.requestId);
|
||||
assert(busyReplyIndex > 0, 'Busy source should receive queued display-only reply after its run completes');
|
||||
assert(storedBusySource.messages[busyReplyIndex - 1]?.role === 'assistant' && /very slow cross-session prompt/.test(String(storedBusySource.messages[busyReplyIndex - 1].content || '')), 'Queued reply should be appended after the source run assistant message');
|
||||
assert(storedBusySource.messages[busyReplyIndex].crossConversation?.autoRun === true, 'Queued reply should mark source auto-run');
|
||||
await nextMessage(messages, ws, (msg) => isSessionCompletionMessage(msg, busySourceSession.sessionId), 8000);
|
||||
storedBusySource = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${busySourceSession.sessionId}.json`), 'utf8'));
|
||||
assert(storedBusySource.messages.slice(busyReplyIndex + 1).some((message) => (
|
||||
message.role === 'assistant' &&
|
||||
/busy source reply requested/.test(String(message.content || '')) &&
|
||||
/子对话/.test(String(message.content || ''))
|
||||
)), 'Busy source should auto-run after the queued child reply is flushed');
|
||||
|
||||
const returnedPendingDetail = await callInternalMcp(port, internalMcpToken, {
|
||||
tool: 'ccweb_get_pending_reply',
|
||||
|
||||
59
server.js
59
server.js
@@ -3230,6 +3230,7 @@ function normalizeCrossConversationReplyState(raw = {}) {
|
||||
status,
|
||||
createdAt: String(raw.createdAt || '').trim() || new Date().toISOString(),
|
||||
hopCount: Math.max(0, Number.parseInt(String(raw.hopCount || 0), 10) || 0),
|
||||
sourceAutoRun: raw.sourceAutoRun !== false,
|
||||
replyText: truncateTextValue(String(raw.replyText || ''), CROSS_CONVERSATION_MAX_CONTENT_CHARS),
|
||||
completedAt: raw.completedAt || null,
|
||||
returnedAt: raw.returnedAt || null,
|
||||
@@ -3339,6 +3340,7 @@ function crossConversationReplySummary(pending = {}) {
|
||||
completedAt: pending.completedAt || null,
|
||||
returnedAt: pending.returnedAt || null,
|
||||
replyMessageId: pending.replyMessageId || null,
|
||||
sourceAutoRun: pending.sourceAutoRun !== false,
|
||||
preview: truncateTextValue(pending.replyText || '', 240),
|
||||
};
|
||||
}
|
||||
@@ -3923,6 +3925,41 @@ function buildCrossConversationReplyContent(targetSession, replyText) {
|
||||
return `线程「${targetTitle}」已返回消息:\n\n${truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS)}`;
|
||||
}
|
||||
|
||||
function buildCrossConversationReplyAutoRunText(targetSession, replyText) {
|
||||
const targetTitle = targetSession?.title || 'Untitled';
|
||||
const body = truncateTextValue(replyText, CROSS_CONVERSATION_MAX_CONTENT_CHARS);
|
||||
return `子对话「${targetTitle}」已返回。请基于以下返回继续当前任务:\n\n${body}`;
|
||||
}
|
||||
|
||||
function startCrossConversationReplyAutoRun(sourceSession, targetSession, replyText, crossConversation) {
|
||||
if (!sourceSession?.id) return false;
|
||||
const runtimeText = buildCrossConversationReplyAutoRunText(targetSession, replyText);
|
||||
const sourceWs = findViewingSessionWs(sourceSession.id);
|
||||
const result = handleMessage(sourceWs, {
|
||||
text: runtimeText,
|
||||
sessionId: sourceSession.id,
|
||||
mode: sourceSession.permissionMode || 'yolo',
|
||||
agent: getSessionAgent(sourceSession),
|
||||
}, {
|
||||
hideInHistory: true,
|
||||
runtimeText,
|
||||
crossConversation,
|
||||
mcpContext: { hopCount: crossConversation?.hopCount || 0 },
|
||||
skipPendingCrossConversationFlush: true,
|
||||
});
|
||||
if (!result?.ok) {
|
||||
plog('WARN', 'cross_conversation_reply_autorun_failed', {
|
||||
sourceSessionId: sourceSession.id.slice(0, 8),
|
||||
targetSessionId: targetSession?.id ? targetSession.id.slice(0, 8) : null,
|
||||
requestId: crossConversation?.replyToRequestId || null,
|
||||
code: result?.code || null,
|
||||
message: result?.message || null,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasProcessedCrossConversationReply(session, requestId) {
|
||||
const normalizedRequestId = String(requestId || '').trim();
|
||||
if (!normalizedRequestId || !Array.isArray(session?.messages)) return false;
|
||||
@@ -3959,6 +3996,7 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop
|
||||
? truncateTextValue(args.content.trim(), CROSS_CONVERSATION_MAX_CONTENT_CHARS)
|
||||
: '';
|
||||
const expectReply = !!options.expectReply;
|
||||
const sourceAutoRun = expectReply && options.sourceAutoRun !== false;
|
||||
|
||||
if (!sourceId) {
|
||||
return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
|
||||
@@ -4013,6 +4051,7 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop
|
||||
status: 'waiting',
|
||||
createdAt: now,
|
||||
hopCount: crossConversation.hopCount,
|
||||
sourceAutoRun,
|
||||
replyText: '',
|
||||
completedAt: null,
|
||||
returnedAt: null,
|
||||
@@ -4051,8 +4090,8 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop
|
||||
...(requestId ? {
|
||||
requestId,
|
||||
status: 'waiting',
|
||||
replyDelivery: 'display_only',
|
||||
sourceAutoRun: false,
|
||||
replyDelivery: sourceAutoRun ? 'auto_run' : 'display_only',
|
||||
sourceAutoRun,
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
@@ -4141,6 +4180,7 @@ function deliverCrossConversationReply(requestId) {
|
||||
const now = new Date().toISOString();
|
||||
const replyMessageId = crypto.randomUUID();
|
||||
const replyContent = buildCrossConversationReplyContent(targetSession, pending.replyText);
|
||||
const sourceAutoRun = pending.sourceAutoRun !== false;
|
||||
const crossConversation = {
|
||||
messageId: replyMessageId,
|
||||
sourceSessionId: targetSession.id,
|
||||
@@ -4151,7 +4191,7 @@ function deliverCrossConversationReply(requestId) {
|
||||
replyToRequestId: requestId,
|
||||
processed: true,
|
||||
processedAt: now,
|
||||
autoRun: false,
|
||||
autoRun: sourceAutoRun,
|
||||
};
|
||||
|
||||
updatePendingCrossConversationReply(requestId, (draft) => {
|
||||
@@ -4185,6 +4225,9 @@ function deliverCrossConversationReply(requestId) {
|
||||
});
|
||||
deletePendingCrossConversationReply(requestId);
|
||||
broadcastSessionList();
|
||||
if (sourceAutoRun) {
|
||||
startCrossConversationReplyAutoRun(sourceSession, targetSession, pending.replyText, crossConversation);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6266,8 +6309,10 @@ function handleMessage(ws, msg, options = {}) {
|
||||
|
||||
let session;
|
||||
if (sessionId) {
|
||||
reconcilePendingCrossConversationReplies();
|
||||
flushPendingCrossConversationReplies(sessionId);
|
||||
if (!options.skipPendingCrossConversationFlush) {
|
||||
reconcilePendingCrossConversationReplies();
|
||||
flushPendingCrossConversationReplies(sessionId);
|
||||
}
|
||||
session = loadSession(sessionId);
|
||||
}
|
||||
if (!session) {
|
||||
@@ -7061,7 +7106,7 @@ function codexAppCommunicationDynamicTools() {
|
||||
},
|
||||
requestReply: {
|
||||
type: 'boolean',
|
||||
description: '可选。若为 true,新对话完成本轮输出后会把回复作为已处理的只读消息写回来源对话,不会再次触发来源对话运行。',
|
||||
description: '可选。若为 true,新对话完成本轮输出后会把回复写回来源对话,并继续触发来源对话运行。',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -7070,7 +7115,7 @@ function codexAppCommunicationDynamicTools() {
|
||||
{
|
||||
name: 'ccweb_request_reply',
|
||||
namespace: 'ccweb',
|
||||
description: '向另一个 cc-web 对话发送消息,并在目标对话完成本轮输出后把回复作为已处理的只读消息写回当前对话;写回不会再次触发当前对话运行。',
|
||||
description: '向另一个 cc-web 对话发送消息,并在目标对话完成本轮输出后把回复写回当前对话,然后继续触发当前对话运行。',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
required: ['targetConversationId', 'content'],
|
||||
|
||||
Reference in New Issue
Block a user