feat: v1.2.8 - Codex双Agent、图片上传、主题系统、会话加载优化

- Codex双Agent接入:共享后端内核,前台隔离会话/设置/导入
- 图片上传:Claude (stream-json) 和 Codex (--image) 均支持拖拽/粘贴/选择上传
- 主题系统:CoolVibe Light 视觉方案,主题入口移至二级页
- 会话加载优化:加载遮罩、热会话缓存、切后台内容不丢失
- 移动端增强:侧栏手势、运行状态标签、按钮比例修复
- 后端重构:agent-runtime.js / codex-rollouts.js 模块拆分
- 回归脚本:npm run regression 隔离式测试
This commit is contained in:
cc-dan
2026-03-13 12:46:34 +00:00
parent 0a42007101
commit 6f381998e9
16 changed files with 5450 additions and 508 deletions

View File

@@ -7,5 +7,8 @@ PORT=8002
# Claude CLI 路径(默认在 PATH 中查找 claude # Claude CLI 路径(默认在 PATH 中查找 claude
CLAUDE_PATH=claude CLAUDE_PATH=claude
# Codex CLI 路径(默认在 PATH 中查找 codex
CODEX_PATH=codex
# PushPlus Token可选首次启动会自动迁移到 config/notify.json # PushPlus Token可选首次启动会自动迁移到 config/notify.json
PUSHPLUS_TOKEN= PUSHPLUS_TOKEN=

2
.gitignore vendored
View File

@@ -1,8 +1,10 @@
node_modules/ node_modules/
sessions/ sessions/
logs/ logs/
attachments/
.env .env
config/notify.json config/notify.json
config/auth.json config/auth.json
config/model.json config/model.json
config/codex.json
CLAUDE.md CLAUDE.md

View File

@@ -1,5 +1,41 @@
# 更新记录 # 更新记录
- **v1.2.8**
- **Codex 双 Agent 接入**
- 新建会话时可选择 Claude 或 Codex复用 cc-web 现有的多会话、后台进程、断线续挂和文件 I/O 核心逻辑
- 侧边栏按 Agent 隔离会话列表、最近会话记忆与默认新建行为
- Agent 切换入口收进顶部栏标签下拉
- Claude / Codex 各有独立设置入口Codex 设置简化为 `local/custom + API Profile`
- Codex 会话通过 `codex exec --json` / `codex exec resume --json` 对接stdin 文件喂入 prompt持久化 thread id 用于续接
- `/model``/cost``/help` 对 Codex 会话做了适配
- **Codex 本地历史导入**
- 扫描 `~/.codex/sessions/` 下的 rollout `.jsonl`,解析用户消息、助手输出、函数调用与 token 使用量后导入到 cc-web
- 删除 Codex 会话时同步清理对应 rollout 文件与本地线程元数据
- **图片上传**
- Claude 和 Codex 会话均支持图片消息:拖拽、粘贴或点击附件按钮上传
- 客户端自动压缩WebP服务端附件缓存7 天 TTL
- Claude 通过 `--input-format stream-json` 传入 base64 图片Codex 通过 `--image` 参数传入
- 历史消息仅保留图片文字标签,不渲染原图
- 单条消息最多 4 张图片
- **会话加载体验优化**
- 回退并收敛长会话加载策略:最近消息先到,更老消息分批补充
- 新增会话加载遮罩与加载期不可操作状态
- 热会话缓存(最近 4 个strong/weak 两级命中策略)
- 修复切后台再切回时运行中内容短暂消失的问题(`preserveStreaming` 机制)
- **主题系统**
- 引入完整的主题系统与 CoolVibe Light 视觉方案
- 主题入口从设置页顶部改为「外观 → 界面主题」二级页
- 加载遮罩适配 washi / coolvibe / editorial 三套主题变量
- **移动端交互增强**
- 对话区任意位置右滑唤起侧栏、侧栏打开后左滑关闭
- 运行中状态覆盖移动端 cwd 标签显示
- 修复附件按钮、新会话分裂按钮的移动端比例失调
- **其他改进**
- 后端重构spawn spec 与事件解析抽离到 `lib/agent-runtime.js`Codex rollout 解析在 `lib/codex-rollouts.js`
- 新增隔离式回归脚本 `npm run regression`,使用 mock CLI 在临时目录中校验主路径
- 设置页说明卡移除Codex Web Search 解释文案移除
- 删除确认弹窗按 Agent 动态提示 Claude / Codex 的本地删除影响
- **v1.2.7** - **v1.2.7**
- 新增导入本地 CLI 会话:扫描 `~/.claude/projects/` 下的 `.jsonl`,解析后导入到 cc-web可续接历史对话。 - 新增导入本地 CLI 会话:扫描 `~/.claude/projects/` 下的 `.jsonl`,解析后导入到 cc-web可续接历史对话。
- 新增新建会话指定工作目录:创建会话时弹窗设置 cwdspawn 子进程时使用该目录header 显示当前工作路径。 - 新增新建会话指定工作目录:创建会话时弹窗设置 cwdspawn 子进程时使用该目录header 显示当前工作路径。

View File

@@ -1,6 +1,6 @@
# CC-Web # CC-Web
A lightweight web interface for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI, so you can run and monitor workflows directly from a browser. A lightweight browser interface for Claude Code and Codex, designed to keep each agent close to its native CLI workflow while sharing the same web shell.
![Node.js](https://img.shields.io/badge/Node.js-22+-339933?logo=node.js&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js-22+-339933?logo=node.js&logoColor=white)
![License](https://img.shields.io/badge/License-MIT-blue) ![License](https://img.shields.io/badge/License-MIT-blue)
@@ -15,21 +15,27 @@ A lightweight web interface for [Claude Code](https://docs.anthropic.com/en/docs
## Features ## Features
- **Lightweight runtime1.6MB**: low backend overhead, browser-based control panel. - **Lightweight runtime**: low backend overhead, browser-based control panel.
- **Dual-agent sessions**: create Claude or Codex sessions on the same backend core.
- **Agent-isolated views**: switching Claude / Codex only shows that agent's sessions, recent state, settings, and import entry points.
- **Agent-specific settings**: Claude keeps template-based model config; Codex has its own path, default model, mode, and search settings.
- **Multi-session management**: create, switch, rename, and delete sessions; deleting a session also removes the local Claude history record. - **Multi-session management**: create, switch, rename, and delete sessions; deleting a session also removes the local Claude history record.
- **Local history import**: import Claude history from `~/.claude/projects/` and Codex rollout history from `~/.codex/sessions/`.
- **Session resume**: context continuity via `--resume`; you can also reattach via SSH + `tmux attach -t claude` when needed. - **Session resume**: context continuity via `--resume`; you can also reattach via SSH + `tmux attach -t claude` when needed.
- **Background task support**: Claude processes continue after browser disconnect and notify you on completion. - **Background task support**: Claude processes continue after browser disconnect and notify you on completion.
- **Multi-channel notifications**: PushPlus / Telegram / ServerChan / Feishu bot / QQ (Qmsg), configurable in Web UI. - **Multi-channel notifications**: PushPlus / Telegram / ServerChan / Feishu bot / QQ (Qmsg), configurable in Web UI.
- **Process persistence**: detached subprocess + PID files; running tasks survive service restarts. - **Process persistence**: detached subprocess + PID files; running tasks survive service restarts.
- **Multi-API switching**: configure multiple API profiles and switch between them instantly from the UI. - **Multi-API switching**: configure multiple API profiles and switch between them instantly from the UI.
- **Password-based auth**: initial password generation, forced first-login reset, and password change in Web UI.
## Requirements ## Requirements
- **Node.js** >= 18 - **Node.js** >= 18
- **Claude Code CLI** installed and configured (`claude` command available) - **Claude Code CLI** and/or **Codex CLI** installed and configured
```bash ```bash
npm install -g @anthropic-ai/claude-code npm install -g @anthropic-ai/claude-code
npm install -g @openai/codex
``` ```
## Quick Start ## Quick Start
@@ -66,6 +72,7 @@ After startup, open `http://localhost:8002` and sign in with your password.
| `CC_WEB_PASSWORD` | No | Auto-generated | Web login password (migrated into `config/auth.json` on first start) | | `CC_WEB_PASSWORD` | No | Auto-generated | Web login password (migrated into `config/auth.json` on first start) |
| `PORT` | No | `8002` | Service port | | `PORT` | No | `8002` | Service port |
| `CLAUDE_PATH` | No | `claude` | Executable path to Claude CLI | | `CLAUDE_PATH` | No | `claude` | Executable path to Claude CLI |
| `CODEX_PATH` | No | `codex` | Executable path to Codex CLI |
| `PUSHPLUS_TOKEN` | No | - | PushPlus token (migrated into notification config on first start) | | `PUSHPLUS_TOKEN` | No | - | PushPlus token (migrated into notification config on first start) |
### Notification Configuration ### Notification Configuration
@@ -107,6 +114,8 @@ cc-web/
│ └── auth.json # Auth config (generated at runtime) │ └── auth.json # Auth config (generated at runtime)
├── sessions/ # Chat history JSON files (generated at runtime) ├── sessions/ # Chat history JSON files (generated at runtime)
├── logs/ # Process lifecycle logs (generated at runtime) ├── logs/ # Process lifecycle logs (generated at runtime)
├── lib/ # Agent runtime + Codex rollout parsing helpers
├── scripts/ # Regression tooling + mock CLIs
├── .env.example # Environment variable template ├── .env.example # Environment variable template
├── start.bat # Windows startup script ├── start.bat # Windows startup script
├── .gitignore ├── .gitignore
@@ -119,10 +128,10 @@ cc-web/
### Process Model ### Process Model
```text ```text
Browser ←WebSocket→ Node.js (server.js) ←file I/O→ Claude CLI (detached) Browser ←WebSocket→ Node.js (server.js) ←file I/O→ Claude / Codex CLI (detached)
``` ```
- Each user message spawns a `claude -p --output-format stream-json` subprocess. - Each user message spawns either a Claude or Codex subprocess depending on the session agent.
- Subprocesses use `detached: true` + `proc.unref()` and run independently from Node.js lifecycle. - Subprocesses use `detached: true` + `proc.unref()` and run independently from Node.js lifecycle.
- stdin/stdout/stderr are bridged via files in `sessions/{id}-run/`. - stdin/stdout/stderr are bridged via files in `sessions/{id}-run/`.
- PID is persisted to disk and recovered after service restart (`recoverProcesses()`). - PID is persisted to disk and recovered after service restart (`recoverProcesses()`).
@@ -216,7 +225,7 @@ server {
### Windows Deployment ### Windows Deployment
Use this mode when running CC-Web on a personal PC and controlling Claude Code from mobile. Use this mode when running CC-Web on a personal PC and controlling Claude / Codex from mobile.
Start with `start.bat`, or run manually: Start with `start.bat`, or run manually:
@@ -235,6 +244,14 @@ node server.js
## Release Notes ## Release Notes
- **v1.2.8**
- **Dual-agent (Codex)**: create Claude or Codex sessions on the same backend; agent-isolated sidebar, settings, and import
- **Image upload**: drag, paste, or attach images in both Claude and Codex sessions; client-side WebP compression, 7-day server cache, up to 4 images per message
- **Session loading**: loading overlay, hot session cache (4 slots, strong/weak hit), fix for streaming content disappearing on tab switch
- **Theme system**: full theme engine with CoolVibe Light, washi, and editorial variants; theme picker moved to sub-page
- **Mobile UX**: swipe-to-open/close sidebar, running-state badge replaces cwd label, button sizing fixes
- **Backend refactor**: spawn spec + event parsing extracted to `lib/agent-runtime.js`; isolated regression script `npm run regression`
- **v1.2.2** - **v1.2.2**
- Aligned context compression with Claude Code native behavior: `/compact` is now actually sent to CLI instead of doing a local pseudo-reset. - Aligned context compression with Claude Code native behavior: `/compact` is now actually sent to CLI instead of doing a local pseudo-reset.
- Added automatic overflow recovery: when `Request too large (max 20MB)` occurs, CC-Web runs `/compact` and replays the failed prompt automatically. - Added automatic overflow recovery: when `Request too large (max 20MB)` occurs, CC-Web runs `/compact` and replays the failed prompt automatically.
@@ -250,4 +267,4 @@ node server.js
## Notes ## Notes
- This project currently targets Claude Code only. - Claude support is still the more mature path, while Codex now supports isolated sessions, resume, import, background execution, and local cleanup.

View File

@@ -1,6 +1,6 @@
# CC-Web # CC-Web
Claude Code 轻量级 Web 远程工具 — 在浏览器中与 [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI 交互。 Claude Code / Codex 轻量级 Web 远程工具 — 在浏览器中与本机 CLI Agent 交互。
![Node.js](https://img.shields.io/badge/Node.js-22+-339933?logo=node.js&logoColor=white) ![Node.js](https://img.shields.io/badge/Node.js-22+-339933?logo=node.js&logoColor=white)
![License](https://img.shields.io/badge/License-MIT-blue) ![License](https://img.shields.io/badge/License-MIT-blue)
@@ -24,20 +24,27 @@ https://github.com/ZgDaniel/cc-web 给我装!
## 功能特性 ## 功能特性
- **超轻量1.6MB** — 后端性能占用少,前端通过 web 访问 - **超轻量** — 后端性能占用少,前端通过 web 访问
- **双 Agent 会话** — 新建会话时可选择 Claude 或 Codex沿用相同的 Web 会话与后台任务模型
- **Agent 视图隔离** — 侧边栏切换 Claude / Codex 后,仅展示当前 Agent 的会话与最近记录,互不干扰
- **独立 Agent 设置** — Claude 与 Codex 拥有各自的设置入口与默认行为,保持贴近各自原生 CLI 的使用方式
- **多会话管理** — 创建、切换、重命名、删除会话,删除时同步清除本地 Claude 历史记录 - **多会话管理** — 创建、切换、重命名、删除会话,删除时同步清除本地 Claude 历史记录
- **会话续接** — 基于 `--resume` 实现跨消息上下文保持,也可通过 SSH 使用 `tmux attach -t claude` 命令加入会话 - **会话续接** — 基于 `--resume` 实现跨消息上下文保持,也可通过 SSH 使用 `tmux attach -t claude` 命令加入会话
- **本地历史导入** — Claude 可导入 `~/.claude/projects/` 会话Codex 可导入 `~/.codex/sessions/` rollout 历史
- **后台任务** — 关闭浏览器后 Claude 进程继续运行,完成后推送通知 - **后台任务** — 关闭浏览器后 Claude 进程继续运行,完成后推送通知
- **多渠道通知** — 支持 PushPlus / Telegram / Server酱 / 飞书机器人 / QQQmsgWeb UI 内可视化配置 - **多渠道通知** — 支持 PushPlus / Telegram / Server酱 / 飞书机器人 / QQQmsgWeb UI 内可视化配置
- **进程持久化** — detached 进程 + PID 文件,服务重启不丢失运行中的任务 - **进程持久化** — detached 进程 + PID 文件,服务重启不丢失运行中的任务
- **多 API 切换** — 可配置多个 API 方案UI 中一键切换,即时生效 - **多 API 切换** — 可配置多个 API 方案UI 中一键切换,即时生效
- **密码认证** — 自动生成初始密码、首次登录强制改密、Web UI 修改密码
- **隔离式回归脚本** — `npm run regression` 在临时目录中使用 mock Claude / Codex CLI 校验主路径,不污染真实数据
## 前提条件 ## 前提条件
- **Node.js** >= 18 - **Node.js** >= 18
- **Claude Code CLI** 已安装并配置`claude` 命令可用) - **Claude Code CLI** **Codex CLI** 已安装并配置
```bash ```bash
npm install -g @anthropic-ai/claude-code npm install -g @anthropic-ai/claude-code
npm install -g @openai/codex
``` ```
## 快速开始 ## 快速开始
@@ -75,6 +82,10 @@ copy .env.example .env & REM 可选
| `CC_WEB_PASSWORD` | 否 | 自动生成 | Web 登录密码(首次启动自动迁移到 `config/auth.json` | | `CC_WEB_PASSWORD` | 否 | 自动生成 | Web 登录密码(首次启动自动迁移到 `config/auth.json` |
| `PORT` | 否 | `8002` | 服务监听端口 | | `PORT` | 否 | `8002` | 服务监听端口 |
| `CLAUDE_PATH` | 否 | `claude` | Claude CLI 可执行文件路径 | | `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首次启动自动迁移到通知配置 | | `PUSHPLUS_TOKEN` | 否 | - | PushPlus Token首次启动自动迁移到通知配置 |
### 通知配置 ### 通知配置
@@ -106,16 +117,24 @@ copy .env.example .env & REM 可选
``` ```
cc-web/ cc-web/
├── server.js # Node.js 后端HTTP + WebSocket + 进程管理 + 通知) ├── server.js # Node.js 后端HTTP + WebSocket + 进程管理 + 通知)
├── lib/
│ ├── agent-runtime.js # Claude / Codex 运行时适配层
│ └── codex-rollouts.js # Codex rollout 历史解析
├── public/ ├── public/
│ ├── index.html # 页面结构 │ ├── index.html # 页面结构
│ ├── app.js # 前端逻辑WebSocket 通信、UI 交互) │ ├── app.js # 前端逻辑WebSocket 通信、UI 交互)
│ ├── style.css # 样式(和风暖色调主题) │ ├── style.css # 样式(和风暖色调主题)
│ └── sw.js # Service Worker移动端推送通知 │ └── sw.js # Service Worker移动端推送通知
├── config/ ├── config/
│ ├── codex.json # Codex 独立配置(运行时生成)
│ ├── notify.json # 通知渠道配置(运行时生成) │ ├── notify.json # 通知渠道配置(运行时生成)
│ └── auth.json # 密码配置(运行时生成) │ └── auth.json # 密码配置(运行时生成)
├── sessions/ # 对话历史 JSON 文件(运行时生成) ├── sessions/ # 对话历史 JSON 文件(运行时生成)
├── logs/ # 进程生命周期日志(运行时生成) ├── logs/ # 进程生命周期日志(运行时生成)
├── scripts/
│ ├── regression.js # 隔离式回归脚本
│ ├── mock-claude.js # 回归用 mock Claude CLI
│ └── mock-codex.js # 回归用 mock Codex CLI
├── .env.example # 环境变量模板 ├── .env.example # 环境变量模板
├── start.bat # Windows 一键启动脚本 ├── start.bat # Windows 一键启动脚本
├── .gitignore ├── .gitignore
@@ -128,14 +147,15 @@ cc-web/
### 进程模型 ### 进程模型
``` ```
浏览器 ←WebSocket→ Node.js (server.js) ←文件I/O→ Claude CLI (detached) 浏览器 ←WebSocket→ Node.js (server.js) ←文件I/O→ Claude / Codex CLI (detached)
``` ```
- 每条用户消息 spawn 一个 `claude -p --output-format stream-json` 子进程 - 每条用户消息会根据当前会话 Agentspawn Claude 或 Codex 子进程
- 进程使用 `detached: true` + `proc.unref()`,独立于 Node.js 生命周期 - 进程使用 `detached: true` + `proc.unref()`,独立于 Node.js 生命周期
- stdin/stdout/stderr 通过文件传递(`sessions/{id}-run/`),不使用 pipe - stdin/stdout/stderr 通过文件传递(`sessions/{id}-run/`),不使用 pipe
- PID 持久化到文件,服务重启后自动恢复(`recoverProcesses()` - PID 持久化到文件,服务重启后自动恢复(`recoverProcesses()`
- 使用 `FileTailer` 实时监听输出文件变化,流式推送给前端 - 使用 `FileTailer` 实时监听输出文件变化,流式推送给前端
- Claude / Codex 的 spawn spec 与事件解析分别由 `lib/agent-runtime.js` 管理
### 后台任务流程 ### 后台任务流程
@@ -247,4 +267,5 @@ node server.js
## 补充说明 ## 补充说明
- 暂时只支持 Claude Code,后续再 vibe codex - 当前已支持 Claude Code 与 CodexClaude 侧能力更完整Codex 侧以会话续接、后台执行和命令流展示为主
- 每次大改动后建议先执行 `npm run regression`

390
lib/agent-runtime.js Normal file
View File

@@ -0,0 +1,390 @@
function createAgentRuntime(deps) {
const {
processEnv,
CLAUDE_PATH,
CODEX_PATH,
MODEL_MAP,
loadModelConfig,
applyCustomTemplateToSettings,
loadCodexConfig,
prepareCodexCustomRuntime,
wsSend,
truncateObj,
sanitizeToolInput,
loadSession,
saveSession,
setRuntimeSessionId,
getRuntimeSessionId,
} = deps;
function buildClaudeSpawnSpec(session, options = {}) {
const hasAttachments = Array.isArray(options.attachments) && options.attachments.length > 0;
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
if (hasAttachments) args.push('--input-format', 'stream-json');
const permMode = session.permissionMode || 'yolo';
switch (permMode) {
case 'yolo':
args.push('--dangerously-skip-permissions');
break;
case 'plan':
args.push('--permission-mode', 'plan');
break;
case 'default':
break;
}
if (session.claudeSessionId) {
args.push('--resume', session.claudeSessionId);
}
if (session.model) {
const validModels = new Set(Object.values(MODEL_MAP));
if (validModels.has(session.model)) {
args.push('--model', session.model);
}
}
const env = { ...processEnv };
delete env.CLAUDECODE;
delete env.CLAUDE_CODE;
delete env.CC_WEB_PASSWORD;
for (const k of Object.keys(env)) {
if (k.startsWith('ANTHROPIC_')) delete env[k];
}
const modelCfg = loadModelConfig();
if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) {
const tpl = (modelCfg.templates || []).find((t) => t.name === modelCfg.activeTemplate);
if (tpl) applyCustomTemplateToSettings(tpl);
}
return {
command: CLAUDE_PATH,
args,
env,
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
parser: 'claude',
mode: permMode,
resume: !!session.claudeSessionId,
};
}
function buildCodexSpawnSpec(session, options = {}) {
const codexConfig = loadCodexConfig();
const runtimeConfig = prepareCodexCustomRuntime(codexConfig);
if (runtimeConfig?.error) {
return { error: runtimeConfig.error };
}
const runtimeId = getRuntimeSessionId(session);
const args = ['exec'];
if (runtimeId) args.push('resume');
args.push('--json', '--skip-git-repo-check');
const permMode = session.permissionMode || 'yolo';
switch (permMode) {
case 'yolo':
args.push('--dangerously-bypass-approvals-and-sandbox');
break;
case 'plan':
args.push('-s', 'read-only');
break;
case 'default':
default:
args.push('--full-auto');
break;
}
const effectiveModel = session.model;
if (effectiveModel) args.push('--model', effectiveModel);
if (Array.isArray(options.attachments)) {
for (const attachment of options.attachments) {
if (attachment?.path) args.push('--image', attachment.path);
}
}
if (runtimeId) {
args.push(runtimeId, '-');
} else {
if (session.cwd) args.push('-C', session.cwd);
args.push('-');
}
const env = { ...processEnv };
delete env.CC_WEB_PASSWORD;
delete env.CLAUDECODE;
delete env.CLAUDE_CODE;
if (runtimeConfig?.mode === 'custom') {
env.CODEX_HOME = runtimeConfig.homeDir;
env.OPENAI_API_KEY = runtimeConfig.apiKey;
delete env.OPENAI_BASE_URL;
}
return {
command: CODEX_PATH,
args,
env,
cwd: session.cwd || processEnv.HOME || processEnv.USERPROFILE || process.cwd(),
parser: 'codex',
mode: permMode,
resume: !!runtimeId,
};
}
function codexToolName(item) {
switch (item?.type) {
case 'command_execution':
return 'CommandExecution';
case 'mcp_tool_call':
return 'McpToolCall';
case 'file_change':
return 'FileChange';
case 'reasoning':
return 'Reasoning';
default:
return item?.type || 'CodexItem';
}
}
function codexToolInput(item) {
if (!item) return null;
if (item.type === 'command_execution') return { command: item.command || '' };
return truncateObj(item, 500);
}
function codexToolMeta(item) {
if (!item) return null;
switch (item.type) {
case 'command_execution':
return {
kind: 'command_execution',
title: 'Shell Command',
subtitle: item.command || '',
exitCode: typeof item.exit_code === 'number' ? item.exit_code : null,
status: item.status || null,
};
case 'mcp_tool_call':
return {
kind: 'mcp_tool_call',
title: 'MCP Tool',
subtitle: item.tool_name || item.name || item.server_name || '',
status: item.status || null,
};
case 'file_change':
return {
kind: 'file_change',
title: 'File Change',
subtitle: item.path || item.file_path || '',
status: item.status || null,
};
case 'reasoning':
return {
kind: 'reasoning',
title: 'Reasoning',
subtitle: typeof item.text === 'string' ? item.text.slice(0, 120) : '',
status: item.status || null,
};
default:
return {
kind: item.type || 'codex_item',
title: codexToolName(item),
subtitle: '',
status: item.status || null,
};
}
}
function codexToolResult(item) {
if (!item) return '';
if (typeof item.aggregated_output === 'string' && item.aggregated_output) return item.aggregated_output;
if (typeof item.text === 'string' && item.text) return item.text;
return JSON.stringify(truncateObj(item, 1200));
}
function ensureCodexToolCall(entry, item) {
let tc = entry.toolCalls.find((t) => t.id === item.id);
if (tc) {
tc.name = codexToolName(item);
tc.kind = item.type || tc.kind || null;
tc.meta = codexToolMeta(item) || tc.meta || null;
if (tc.input == null) tc.input = codexToolInput(item);
return tc;
}
tc = {
name: codexToolName(item),
id: item.id,
kind: item.type || null,
meta: codexToolMeta(item),
input: codexToolInput(item),
done: false,
};
entry.toolCalls.push(tc);
wsSend(entry.ws, {
type: 'tool_start',
name: tc.name,
toolUseId: item.id,
input: tc.input,
kind: tc.kind,
meta: tc.meta,
});
return tc;
}
function processClaudeEvent(entry, event, sessionId) {
if (!event || !event.type) return;
switch (event.type) {
case 'system':
if (event.session_id) {
const session = loadSession(sessionId);
if (session) {
session.claudeSessionId = event.session_id;
saveSession(session);
}
}
break;
case 'assistant': {
const content = event.message?.content;
if (!Array.isArray(content)) break;
for (const block of content) {
if (block.type === 'text' && block.text) {
entry.fullText += block.text;
wsSend(entry.ws, { type: 'text_delta', text: block.text });
} else if (block.type === 'tool_use') {
const toolInput = sanitizeToolInput(block.name, block.input);
const tc = { name: block.name, id: block.id, input: toolInput, done: false };
entry.toolCalls.push(tc);
wsSend(entry.ws, { type: 'tool_start', name: block.name, toolUseId: block.id, input: tc.input });
} else if (block.type === 'tool_result') {
const resultText = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content.map((c) => c.text || '').join('\n')
: JSON.stringify(block.content);
const tc = entry.toolCalls.find((t) => t.id === block.tool_use_id);
if (tc) {
tc.done = true;
tc.result = resultText.slice(0, 2000);
}
wsSend(entry.ws, { type: 'tool_end', toolUseId: block.tool_use_id, result: resultText.slice(0, 2000) });
}
}
if (event.session_id) {
const session = loadSession(sessionId);
if (session && !session.claudeSessionId) {
session.claudeSessionId = event.session_id;
saveSession(session);
}
}
break;
}
case 'result': {
const session = loadSession(sessionId);
if (session) {
if (event.session_id) session.claudeSessionId = event.session_id;
if (event.total_cost_usd) session.totalCost = (session.totalCost || 0) + event.total_cost_usd;
saveSession(session);
}
entry.lastCost = event.total_cost_usd || null;
if (entry.ws && event.total_cost_usd !== undefined) {
wsSend(entry.ws, { type: 'cost', costUsd: session?.totalCost || 0 });
}
break;
}
}
}
function processCodexEvent(entry, event, sessionId) {
if (!event || !event.type) return;
switch (event.type) {
case 'thread.started': {
if (!event.thread_id) break;
const session = loadSession(sessionId);
if (session) {
setRuntimeSessionId(session, event.thread_id);
saveSession(session);
}
break;
}
case 'item.started': {
const item = event.item;
if (!item || !item.id || item.type === 'agent_message') break;
ensureCodexToolCall(entry, item);
break;
}
case 'item.completed': {
const item = event.item;
if (!item || !item.id) break;
if (item.type === 'agent_message') {
if (item.text) {
entry.fullText += item.text;
wsSend(entry.ws, { type: 'text_delta', text: item.text });
}
break;
}
const tc = ensureCodexToolCall(entry, item);
const resultText = codexToolResult(item).slice(0, 2000);
tc.done = true;
tc.result = resultText;
wsSend(entry.ws, {
type: 'tool_end',
toolUseId: item.id,
result: resultText,
kind: tc.kind,
meta: tc.meta,
});
break;
}
case 'turn.completed': {
const usage = event.usage || null;
entry.lastUsage = usage;
const session = loadSession(sessionId);
if (session && usage) {
session.totalUsage = {
inputTokens: (session.totalUsage?.inputTokens || 0) + (usage.input_tokens || 0),
cachedInputTokens: (session.totalUsage?.cachedInputTokens || 0) + (usage.cached_input_tokens || 0),
outputTokens: (session.totalUsage?.outputTokens || 0) + (usage.output_tokens || 0),
};
saveSession(session);
wsSend(entry.ws, { type: 'usage', totalUsage: session.totalUsage });
}
break;
}
case 'turn.failed': {
const message = event.error?.message || 'Codex 任务失败';
entry.lastError = message;
break;
}
case 'error':
if (event.message) {
if (/^Reconnecting\.\.\./.test(event.message)) {
wsSend(entry.ws, { type: 'system_message', message: event.message });
} else {
entry.lastError = event.message;
}
}
break;
}
}
function processRuntimeEvent(entry, event, sessionId) {
if (entry.agent === 'codex') processCodexEvent(entry, event, sessionId);
else processClaudeEvent(entry, event, sessionId);
}
return {
buildClaudeSpawnSpec,
buildCodexSpawnSpec,
processClaudeEvent,
processCodexEvent,
processRuntimeEvent,
};
}
module.exports = { createAgentRuntime };

205
lib/codex-rollouts.js Normal file
View File

@@ -0,0 +1,205 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function createCodexRolloutStore(deps) {
const { codexSessionsDir, sessionsDir, normalizeSession, sanitizeToolInput } = deps;
function extractCodexMessageText(content) {
if (!Array.isArray(content)) return '';
return content
.filter((item) => item && (item.type === 'input_text' || item.type === 'output_text'))
.map((item) => item.text || '')
.join('');
}
function appendAssistantContent(turn, text) {
if (!turn || !text || !text.trim()) return;
turn.content = turn.content ? `${turn.content}\n\n${text}` : text;
}
function parseCodexRolloutLines(lines) {
const messages = [];
const pendingToolCalls = new Map();
const meta = { threadId: null, cwd: null, title: '', updatedAt: null, cliVersion: null, source: null };
const totalUsage = { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
let currentAssistant = null;
let sawRealUserMessage = false;
const fallbackUserMessages = [];
function ensureAssistant(ts) {
if (!currentAssistant) {
currentAssistant = { role: 'assistant', content: '', toolCalls: [], timestamp: ts || null };
} else if (!currentAssistant.timestamp && ts) {
currentAssistant.timestamp = ts;
}
return currentAssistant;
}
function flushAssistant() {
if (!currentAssistant) return;
if ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0) {
messages.push(currentAssistant);
}
currentAssistant = null;
pendingToolCalls.clear();
}
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let entry;
try { entry = JSON.parse(trimmed); } catch { continue; }
const ts = entry.timestamp || null;
if (ts) meta.updatedAt = ts;
if (entry.type === 'session_meta') {
meta.threadId = entry.payload?.id || meta.threadId;
meta.cwd = entry.payload?.cwd || meta.cwd;
meta.cliVersion = entry.payload?.cli_version || meta.cliVersion;
meta.source = entry.payload?.source || meta.source;
continue;
}
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count') {
const total = entry.payload?.info?.total_token_usage || null;
const usage = entry.payload?.info?.last_token_usage || null;
if (total) {
totalUsage.inputTokens = Math.max(totalUsage.inputTokens, total.input_tokens || 0);
totalUsage.cachedInputTokens = Math.max(totalUsage.cachedInputTokens, total.cached_input_tokens || 0);
totalUsage.outputTokens = Math.max(totalUsage.outputTokens, total.output_tokens || 0);
} else if (usage) {
totalUsage.inputTokens += usage.input_tokens || 0;
totalUsage.cachedInputTokens += usage.cached_input_tokens || 0;
totalUsage.outputTokens += usage.output_tokens || 0;
}
continue;
}
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
const text = String(entry.payload?.message || '').trim();
if (text) {
sawRealUserMessage = true;
flushAssistant();
if (!meta.title) meta.title = text.slice(0, 80).replace(/\n/g, ' ');
messages.push({ role: 'user', content: text, timestamp: ts });
}
continue;
}
if (entry.type !== 'response_item') continue;
const payload = entry.payload || {};
switch (payload.type) {
case 'message': {
if (payload.role === 'assistant') {
const text = extractCodexMessageText(payload.content);
if (text.trim()) {
if (currentAssistant && ((currentAssistant.content || '').trim() || currentAssistant.toolCalls.length > 0)) {
flushAssistant();
}
appendAssistantContent(ensureAssistant(ts), text);
}
} else if (payload.role === 'user' && !sawRealUserMessage) {
const text = extractCodexMessageText(payload.content);
if (text.trim()) {
fallbackUserMessages.push({ role: 'user', content: text, timestamp: ts });
}
}
break;
}
case 'function_call': {
const assistant = ensureAssistant(ts);
const toolUseId = payload.call_id || payload.id || crypto.randomUUID();
const tc = {
name: payload.name || 'FunctionCall',
id: toolUseId,
input: sanitizeToolInput(payload.name || 'FunctionCall', payload.arguments || ''),
done: false,
};
assistant.toolCalls.push(tc);
pendingToolCalls.set(toolUseId, tc);
break;
}
case 'function_call_output': {
const assistant = ensureAssistant(ts);
const toolUseId = payload.call_id || crypto.randomUUID();
let tc = pendingToolCalls.get(toolUseId);
if (!tc) {
tc = { name: 'FunctionCall', id: toolUseId, input: null, done: false };
assistant.toolCalls.push(tc);
pendingToolCalls.set(toolUseId, tc);
}
tc.done = true;
tc.result = (typeof payload.output === 'string'
? payload.output
: JSON.stringify(payload.output || '')).slice(0, 2000);
break;
}
default:
break;
}
}
flushAssistant();
if (!sawRealUserMessage && fallbackUserMessages.length > 0) {
const fallback = fallbackUserMessages[0];
if (!meta.title) meta.title = fallback.content.trim().slice(0, 80).replace(/\n/g, ' ');
return { meta, messages: fallbackUserMessages.concat(messages), totalUsage };
}
return { meta, messages, totalUsage };
}
function walkFiles(dir, files = []) {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) walkFiles(fullPath, files);
else if (entry.isFile()) files.push(fullPath);
}
return files;
}
function getCodexRolloutFiles() {
if (!fs.existsSync(codexSessionsDir)) return [];
return walkFiles(codexSessionsDir, []).filter((filePath) => filePath.endsWith('.jsonl')).sort().reverse();
}
function getImportedCodexThreadIds() {
const imported = new Set();
try {
for (const f of fs.readdirSync(sessionsDir).filter((name) => name.endsWith('.json'))) {
try {
const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf8')));
if (session.codexThreadId) imported.add(session.codexThreadId);
} catch {}
}
} catch {}
return imported;
}
function parseCodexRolloutFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const parsed = parseCodexRolloutLines(content.split('\n'));
parsed.filePath = filePath;
return parsed;
} catch {
return null;
}
}
return {
parseCodexRolloutLines,
getCodexRolloutFiles,
getImportedCodexThreadIds,
parseCodexRolloutFile,
};
}
module.exports = { createCodexRolloutStore };

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "cc-web", "name": "cc-web",
"version": "1.0.0", "version": "1.2.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cc-web", "name": "cc-web",
"version": "1.0.0", "version": "1.2.8",
"dependencies": { "dependencies": {
"ws": "^8.18.0" "ws": "^8.18.0"
} }

View File

@@ -1,9 +1,10 @@
{ {
"name": "cc-web", "name": "cc-web",
"version": "1.1.0", "version": "1.2.8",
"private": true, "private": true,
"scripts": { "scripts": {
"start": "node server.js" "start": "node server.js",
"regression": "node scripts/regression.js"
}, },
"dependencies": { "dependencies": {
"ws": "^8.18.0" "ws": "^8.18.0"

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,12 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<title>CC-Web</title> <title>CC-Web</title>
<script>
(function () {
var theme = localStorage.getItem('cc-web-theme') || 'washi';
document.documentElement.dataset.theme = theme;
})();
</script>
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
</head> </head>
@@ -15,7 +21,7 @@
<div class="login-box"> <div class="login-box">
<div class="login-logo">CC</div> <div class="login-logo">CC</div>
<h2>CC-Web</h2> <h2>CC-Web</h2>
<p>Claude Code Web Chat</p> <p>Claude / Codex Web Chat</p>
<form id="login-form"> <form id="login-form">
<input type="password" id="login-password" placeholder="输入密码" autocomplete="current-password" autofocus> <input type="password" id="login-password" placeholder="输入密码" autocomplete="current-password" autofocus>
<label class="remember-label"> <label class="remember-label">
@@ -54,13 +60,19 @@
<header class="chat-header"> <header class="chat-header">
<button id="menu-btn" class="menu-btn" title="菜单"></button> <button id="menu-btn" class="menu-btn" title="菜单"></button>
<span id="chat-title" class="chat-title">新会话</span> <span id="chat-title" class="chat-title">新会话</span>
<button id="chat-agent-btn" class="chat-agent-btn" type="button" aria-haspopup="menu" aria-expanded="false">Claude</button>
<div id="chat-agent-menu" class="chat-agent-menu" hidden>
<button type="button" class="chat-agent-option active" data-agent="claude">Claude</button>
<button type="button" class="chat-agent-option" data-agent="codex">Codex</button>
</div>
<span id="chat-runtime-state" class="chat-runtime-state" hidden>运行中</span>
<span id="chat-cwd" class="chat-cwd" hidden></span> <span id="chat-cwd" class="chat-cwd" hidden></span>
<select id="mode-select" class="mode-select" title="权限模式"> <select id="mode-select" class="mode-select" title="权限模式">
<option value="yolo">YOLO</option> <option value="yolo">YOLO</option>
<option value="default">默认</option> <option value="default">默认</option>
<option value="plan">Plan</option> <option value="plan">Plan</option>
</select> </select>
<span id="cost-display" class="cost-display"></span> <span id="cost-display" class="cost-display" hidden></span>
</header> </header>
<div class="messages-wrap"> <div class="messages-wrap">
@@ -68,7 +80,7 @@
<div class="welcome-msg"> <div class="welcome-msg">
<div class="welcome-icon"></div> <div class="welcome-icon"></div>
<h3>欢迎使用 CC-Web</h3> <h3>欢迎使用 CC-Web</h3>
<p>开始与 Claude Code 对话</p> <p>开始与 Claude 对话</p>
</div> </div>
</div> </div>
<div class="custom-scrollbar" id="custom-scrollbar"> <div class="custom-scrollbar" id="custom-scrollbar">
@@ -80,7 +92,14 @@
<div id="cmd-menu" class="cmd-menu" hidden></div> <div id="cmd-menu" class="cmd-menu" hidden></div>
<div class="input-area"> <div class="input-area">
<div id="attachment-tray" class="attachment-tray" hidden></div>
<div class="input-wrapper"> <div class="input-wrapper">
<input id="image-upload-input" type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden>
<button id="attach-btn" class="attach-btn" title="上传图片" type="button">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-8.49 8.49a6 6 0 1 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
</svg>
</button>
<textarea id="msg-input" rows="1" placeholder="输入消息… 输入 / 查看指令" autocomplete="off"></textarea> <textarea id="msg-input" rows="1" placeholder="输入消息… 输入 / 查看指令" autocomplete="off"></textarea>
<button id="send-btn" class="send-btn" title="发送"> <button id="send-btn" class="send-btn" title="发送">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
@@ -97,6 +116,17 @@
</main> </main>
</div> </div>
<div id="session-loading-overlay" class="session-loading-overlay" hidden aria-live="polite" aria-hidden="true">
<div class="session-loading-card" role="status" aria-busy="true">
<div class="session-loading-badge">Session</div>
<div class="session-loading-title">会话加载中</div>
<div id="session-loading-label" class="session-loading-label">正在整理消息与上下文…</div>
<div class="session-loading-bar">
<span class="session-loading-bar-fill"></span>
</div>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="app.js"></script> <script src="app.js"></script>

File diff suppressed because it is too large Load Diff

52
scripts/mock-claude.js Executable file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env node
const crypto = require('crypto');
function readStdin() {
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
});
}
(async function main() {
const args = process.argv.slice(2);
const resumeIndex = args.indexOf('--resume');
const inputFormatIndex = args.indexOf('--input-format');
const sessionId = resumeIndex >= 0 && args[resumeIndex + 1]
? args[resumeIndex + 1]
: crypto.randomUUID();
const input = (await readStdin()).trim();
const usesStreamJson = inputFormatIndex >= 0 && args[inputFormatIndex + 1] === 'stream-json';
process.stdout.write(`${JSON.stringify({ type: 'system', session_id: sessionId })}\n`);
let text = '';
if (usesStreamJson) {
let payload = null;
try { payload = JSON.parse(input.split('\n').find(Boolean) || '{}'); } catch {}
const blocks = payload?.message?.content || [];
const imageCount = blocks.filter((block) => block.type === 'image').length;
const promptText = blocks.filter((block) => block.type === 'text').map((block) => block.text || '').join(' ').trim();
text = `Claude mock handled stream-json (${imageCount} image): ${promptText || '[no text]'}`;
} else if (input === '/compact') {
text = 'Claude compact finished.';
} else {
text = `Claude mock handled: ${input}`;
}
process.stdout.write(`${JSON.stringify({
type: 'assistant',
session_id: sessionId,
message: { content: [{ type: 'text', text }] },
})}\n`);
process.stdout.write(`${JSON.stringify({
type: 'result',
session_id: sessionId,
total_cost_usd: 0,
})}\n`);
})();

62
scripts/mock-codex.js Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env node
const crypto = require('crypto');
function readStdin() {
return new Promise((resolve) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => { data += chunk; });
process.stdin.on('end', () => resolve(data));
});
}
(async function main() {
const args = process.argv.slice(2);
const isResume = args[0] === 'exec' && args[1] === 'resume';
const threadId = isResume && args[2] ? args[2] : `mock-${crypto.randomUUID()}`;
const input = (await readStdin()).trim();
const imageCount = args.filter((arg) => arg === '--image').length;
process.stdout.write(`${JSON.stringify({ type: 'thread.started', thread_id: threadId })}\n`);
process.stdout.write(`${JSON.stringify({ type: 'turn.started' })}\n`);
if (/pwd/i.test(input)) {
process.stdout.write(`${JSON.stringify({
type: 'item.started',
item: {
id: 'item_cmd',
type: 'command_execution',
command: '/bin/bash -lc pwd',
aggregated_output: '',
exit_code: null,
status: 'in_progress',
},
})}\n`);
process.stdout.write(`${JSON.stringify({
type: 'item.completed',
item: {
id: 'item_cmd',
type: 'command_execution',
command: '/bin/bash -lc pwd',
aggregated_output: '/tmp/mock-codex\n',
exit_code: 0,
status: 'completed',
},
})}\n`);
}
process.stdout.write(`${JSON.stringify({
type: 'item.completed',
item: {
id: 'item_msg',
type: 'agent_message',
text: `Codex mock handled (${imageCount} image): ${input}`,
},
})}\n`);
process.stdout.write(`${JSON.stringify({
type: 'turn.completed',
usage: { input_tokens: 10, cached_input_tokens: 2, output_tokens: 5 },
})}\n`);
})();

403
scripts/regression.js Normal file
View File

@@ -0,0 +1,403 @@
#!/usr/bin/env node
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const WebSocket = require('ws');
const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
function mkdirp(dir) {
fs.mkdirSync(dir, { recursive: true });
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
function sql(dbPath, statement) {
const result = spawnSync('sqlite3', [dbPath, statement], { encoding: 'utf8' });
if (result.status !== 0) throw new Error(result.stderr || `sqlite3 failed: ${statement}`);
return result.stdout.trim();
}
async function waitForPort(port, timeoutMs = 10000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const probe = spawnSync('bash', ['-lc', `ss -tln | grep -q ':${port} '`], { encoding: 'utf8' });
if (probe.status === 0) return;
await sleep(100);
}
throw new Error(`Timed out waiting for port ${port}`);
}
async function withServer(env, fn) {
const child = spawn('/usr/bin/node', [SERVER_PATH], {
cwd: REPO_DIR,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
try {
await waitForPort(env.PORT, 10000);
await fn({ child, stdout: () => stdout, stderr: () => stderr });
} finally {
child.kill('SIGTERM');
await sleep(300);
if (!child.killed) child.kill('SIGKILL');
}
}
function connectWs(port, password) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
const messages = [];
ws.on('open', () => {
ws.send(JSON.stringify({ type: 'auth', password }));
});
ws.on('message', (buf) => {
const msg = JSON.parse(String(buf));
messages.push(msg);
if (msg.type === 'auth_result' && msg.success) resolve({ ws, messages, token: msg.token });
if (msg.type === 'auth_result' && !msg.success) reject(new Error('Auth failed'));
});
ws.on('error', reject);
});
}
async function uploadAttachment(port, token, { filename, mime, data }) {
const response = await fetch(`http://127.0.0.1:${port}/api/attachments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': mime,
'X-Filename': encodeURIComponent(filename),
},
body: data,
});
const payload = await response.json();
assert(response.ok && payload.ok, `Attachment upload failed: ${payload.message || response.status}`);
return payload.attachment;
}
function nextMessage(messages, ws, predicate, timeoutMs = 5000) {
return new Promise((resolve, reject) => {
const started = Date.now();
const timer = setInterval(() => {
const found = messages.find(predicate);
if (found) {
clearInterval(timer);
resolve(found);
return;
}
if (Date.now() - started > timeoutMs) {
clearInterval(timer);
reject(new Error('Timed out waiting for expected WebSocket message'));
}
}, 50);
});
}
function createFakeClaudeHistory(homeDir) {
const projectDir = path.join(homeDir, '.claude', 'projects', 'tmp-project');
mkdirp(projectDir);
const sessionId = 'claude-import-test';
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({
type: 'user',
cwd: '/tmp/project-a',
timestamp: '2026-03-12T00:00:00.000Z',
message: { content: 'Claude import prompt' },
}),
JSON.stringify({
type: 'assistant',
timestamp: '2026-03-12T00:00:02.000Z',
message: { content: [{ type: 'text', text: 'Claude import answer' }] },
}),
];
fs.writeFileSync(filePath, `${lines.join('\n')}\n`);
return { sessionId, projectDir: 'tmp-project', filePath };
}
function createFakeCodexHistory(homeDir) {
const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12');
mkdirp(sessionsDir);
const threadId = 'codex-import-thread';
const rolloutPath = path.join(sessionsDir, 'rollout-2026-03-12T00-00-00-codex-import-thread.jsonl');
const rolloutLines = [
JSON.stringify({
timestamp: '2026-03-12T00:00:00.000Z',
type: 'session_meta',
payload: { id: threadId, cwd: '/tmp/project-b', cli_version: '0.114.0', source: 'exec' },
}),
JSON.stringify({
timestamp: '2026-03-12T00:00:00.100Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: '# AGENTS.md wrapper should be ignored' }],
},
}),
JSON.stringify({
timestamp: '2026-03-12T00:00:01.000Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'Codex import prompt' },
}),
JSON.stringify({
timestamp: '2026-03-12T00:00:02.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Codex import answer' }],
},
}),
JSON.stringify({
timestamp: '2026-03-12T00:00:03.000Z',
type: 'event_msg',
payload: {
type: 'token_count',
info: { total_token_usage: { input_tokens: 20, cached_input_tokens: 5, output_tokens: 8 } },
},
}),
];
fs.writeFileSync(rolloutPath, `${rolloutLines.join('\n')}\n`);
const stateDb = path.join(homeDir, '.codex', 'state_5.sqlite');
mkdirp(path.dirname(stateDb));
sql(stateDb, `
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS threads (
id TEXT PRIMARY KEY,
rollout_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT NOT NULL,
model_provider TEXT NOT NULL,
cwd TEXT NOT NULL,
title TEXT NOT NULL,
sandbox_policy TEXT NOT NULL,
approval_mode TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
has_user_event INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
archived_at INTEGER,
git_sha TEXT,
git_branch TEXT,
git_origin_url TEXT,
cli_version TEXT NOT NULL DEFAULT '',
first_user_message TEXT NOT NULL DEFAULT '',
agent_nickname TEXT,
agent_role TEXT,
memory_mode TEXT NOT NULL DEFAULT 'enabled'
);
CREATE TABLE IF NOT EXISTS stage1_outputs (
thread_id TEXT PRIMARY KEY,
source_updated_at INTEGER NOT NULL,
raw_memory TEXT NOT NULL,
rollout_summary TEXT NOT NULL,
generated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS thread_dynamic_tools (
thread_id TEXT NOT NULL,
position INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
input_schema TEXT NOT NULL,
PRIMARY KEY(thread_id, position)
);
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ts_nanos INTEGER NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT,
module_path TEXT,
file TEXT,
line INTEGER,
thread_id TEXT,
process_uuid TEXT,
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version)
VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, 'exec', 'OpenAI', '/tmp/project-b', 'Codex import prompt', '{}', 'never', '0.114.0');
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
`);
const logsDb = path.join(homeDir, '.codex', 'logs_1.sqlite');
sql(logsDb, `
CREATE TABLE IF NOT EXISTS logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
ts_nanos INTEGER NOT NULL,
level TEXT NOT NULL,
target TEXT NOT NULL,
message TEXT,
module_path TEXT,
file TEXT,
line INTEGER,
thread_id TEXT,
process_uuid TEXT,
estimated_bytes INTEGER NOT NULL DEFAULT 0
);
INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}');
`);
return { threadId, rolloutPath, stateDb, logsDb };
}
async function main() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const homeDir = path.join(tempRoot, 'home');
mkdirp(configDir);
mkdirp(sessionsDir);
mkdirp(logsDir);
mkdirp(homeDir);
fs.writeFileSync(path.join(configDir, 'notify.json'), JSON.stringify({
provider: 'off',
pushplus: { token: '' },
telegram: { botToken: '', chatId: '' },
serverchan: { sendKey: '' },
feishu: { webhook: '' },
qqbot: { qmsgKey: '' },
}, null, 2));
createFakeClaudeHistory(homeDir);
const codexFixture = createFakeCodexHistory(homeDir);
const port = 9102;
const password = 'Regression!234';
await withServer({
PORT: String(port),
CC_WEB_PASSWORD: password,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
HOME: homeDir,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX,
}, async () => {
const { ws, messages, token } = await connectWs(port, password);
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
ws.send(JSON.stringify({
type: 'save_codex_config',
config: {
mode: 'custom',
activeProfile: 'Regression Profile',
profiles: [{ name: 'Regression Profile', apiKey: 'sk-regression', apiBase: 'https://example.com/v1' }],
enableSearch: true,
},
}));
const codexConfigMsg = await nextMessage(messages, ws, (msg) => msg.type === 'codex_config');
assert(codexConfigMsg.config.mode === 'custom', 'Codex config mode save/load failed');
assert(codexConfigMsg.config.activeProfile === 'Regression Profile', 'Codex active profile save/load failed');
assert(Array.isArray(codexConfigMsg.config.profiles) && codexConfigMsg.config.profiles[0]?.apiKey.includes('****'), 'Codex profile API key should be masked');
assert(codexConfigMsg.config.supportsSearch === false, 'Codex config should expose unsupported search capability');
assert(codexConfigMsg.config.enableSearch === false, 'Codex config should ignore unsupported search toggle');
ws.send(JSON.stringify({ type: 'new_session', agent: 'codex', cwd: '/tmp/codex-space', mode: 'plan' }));
const codexSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.cwd === '/tmp/codex-space');
assert(codexSession.mode === 'plan', 'Codex new_session should follow requested mode');
assert(codexSession.model === null, 'Codex new_session should not inject a default model');
ws.send(JSON.stringify({ type: 'message', text: '/model gpt-5.3-codex', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex' }));
const codexModelChanged = await nextMessage(messages, ws, (msg) => msg.type === 'model_changed' && msg.model === 'gpt-5.3-codex');
assert(codexModelChanged.model === 'gpt-5.3-codex', 'Codex /model should accept arbitrary Codex model names');
const codexAttachment = await uploadAttachment(port, token, {
filename: 'codex-test.png',
mime: 'image/png',
data: Buffer.from('codex-image'),
});
ws.send(JSON.stringify({ type: 'message', text: 'first codex prompt', attachments: [codexAttachment], mode: 'yolo', agent: 'codex' }));
const firstMessageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'first codex prompt');
assert(firstMessageSession.agent === 'codex', 'First-message path created wrong agent');
const runningSessionList = await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && msg.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning));
assert(runningSessionList.sessions.some((s) => s.id === firstMessageSession.sessionId && s.isRunning), 'Running Codex session should be marked as isRunning');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === firstMessageSession.sessionId);
const processLog = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8');
const spawnLine = processLog
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(firstMessageSession.sessionId.slice(0, 8)));
assert(spawnLine && !spawnLine.includes('--search') && spawnLine.includes('--image'), 'Codex exec should attach images and not append unsupported --search flag');
const runtimeToml = fs.readFileSync(path.join(configDir, 'codex-runtime-home', 'config.toml'), 'utf8');
assert(runtimeToml.includes('preferred_auth_method = "apikey"'), 'Codex custom profile should write isolated runtime auth mode');
assert(runtimeToml.includes('base_url = "https://example.com/v1"'), 'Codex custom profile should write isolated runtime base_url');
const claudeAttachment = await uploadAttachment(port, token, {
filename: 'claude-test.png',
mime: 'image/png',
data: Buffer.from('claude-image'),
});
ws.send(JSON.stringify({ type: 'message', text: 'describe attachment', attachments: [claudeAttachment], mode: 'yolo', agent: 'claude' }));
const claudeImageSession = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'describe attachment');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === claudeImageSession.sessionId);
const claudeSpawnLine = fs.readFileSync(path.join(logsDir, 'process.log'), 'utf8')
.trim()
.split('\n')
.find((line) => line.includes(`"event":"process_spawn"`) && line.includes(claudeImageSession.sessionId.slice(0, 8)));
assert(claudeSpawnLine && claudeSpawnLine.includes('--input-format stream-json'), 'Claude image message should switch stdin to stream-json');
const storedClaudeSession = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${claudeImageSession.sessionId}.json`), 'utf8'));
assert(Array.isArray(storedClaudeSession.messages?.[0]?.attachments) && storedClaudeSession.messages[0].attachments.length === 1, 'Claude message should persist attachment metadata');
ws.send(JSON.stringify({ type: 'list_native_sessions' }));
const nativeSessions = await nextMessage(messages, ws, (msg) => msg.type === 'native_sessions');
assert(nativeSessions.groups?.length > 0, 'Claude native session listing failed');
const firstClaude = nativeSessions.groups[0].sessions[0];
ws.send(JSON.stringify({ type: 'import_native_session', sessionId: firstClaude.sessionId, projectDir: nativeSessions.groups[0].dir }));
const importedClaude = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'claude' && msg.title === 'Claude import prompt');
assert(importedClaude.messages?.[0]?.content === 'Claude import prompt', 'Claude import parsed wrong first message');
ws.send(JSON.stringify({ type: 'list_codex_sessions' }));
const codexSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions');
const importedCodexItem = codexSessions.sessions.find((item) => item.threadId === codexFixture.threadId);
assert(importedCodexItem, 'Codex session listing failed');
ws.send(JSON.stringify({ type: 'import_codex_session', threadId: importedCodexItem.threadId, rolloutPath: importedCodexItem.rolloutPath }));
const importedCodex = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codex' && msg.title === 'Codex import prompt');
assert(importedCodex.messages?.[0]?.content === 'Codex import prompt', 'Codex import kept wrapper instructions');
assert(importedCodex.totalUsage?.inputTokens === 20, 'Codex import usage parse failed');
const importedSessionId = importedCodex.sessionId;
ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedSessionId }));
await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedSessionId));
assert(!fs.existsSync(path.join(sessionsDir, `${importedSessionId}.json`)), 'Deleting Codex session did not remove session JSON');
assert(!fs.existsSync(codexFixture.rolloutPath), 'Deleting Codex session did not remove rollout file');
assert(sql(codexFixture.stateDb, `select count(*) from threads where id='${codexFixture.threadId}'`) === '0', 'Deleting Codex session did not remove thread row');
ws.close();
console.log('Regression checks passed.');
});
}
main().catch((err) => {
console.error(err.stack || err.message);
process.exit(1);
});

1260
server.js

File diff suppressed because it is too large Load Diff