ClickHouse diff-review:零依赖的本地浏览器 Diff 评审技能,从 server.mjs 实现到退出码语义全解析
本篇围绕 ClickHouse 仓库中 .claude/skills/diff-review/ 目录下的 diff-review 技能展开:它把未提交或已提交的本地改动通过一个绑定 loopback 的 Node 服务渲染成浏览器内可逐行批注的 diff 页面,并把用户的行评论以结构化 JSON 回传给 Agent 逐条处理。读完本文,你能完整掌握该技能的启动命令、全部命令行参数与 JSON 输出格式,理解 server.mjs 中 diff 收集、远程环境探测、会话 nonce 鉴权与安全边界的实现细节,以及退出码 0/1/3/4 各自的语义与故障排查方法。
技能定位:提交前让"人"在浏览器里评审 Agent 的改动
diff-review 是 ClickHouse 仓库 .claude/skills/ 目录下的一组 Claude Code 开发工作流技能之一(同目录还有 review、bisect、investigate-ci 等技能)。它解决的具体场景是:在 Agent 执行 git commit 或打开 PR 之前,让用户(人类开发者)在自己的浏览器里阅读全部待提交改动,逐行留下评论,并把这批评论作为一次正式的代码评审交还给 Agent——Agent 必须处理每一条评论(要么改代码,要么回答问题)。
按 SKILL.md 的定义,工作闭环是:
- 以后台任务方式启动本地服务,渲染 diff;
- Agent 告知用户评审地址后进入等待,不得自己抓取该 URL(来自 Agent 的请求会被误判为"浏览器已打开");
- 服务在用户点击 UI 中的 Finish review 后退出,把行评论写入
--out指定的 JSON 文件; - Agent 读取 JSON,逐条引用并处理每条评论;修复完成后建议再跑一轮评审,然后才提交。
何时不要使用这个技能
SKILL.md 中专门有一节 "When not to use it",核心约束来自一个事实:服务只绑定 loopback(127.0.0.1)。因此 UI 只有两种可达方式——
- 服务器所在机器本机的浏览器;
- 用户自己的机器通过 SSH 端口转发(当机器"看起来是远程的"时,服务会打印出可直接复制的
ssh -L命令)。
它无法存活的场景是无人值守的隔离 VM:既然永远不会有浏览器打开页面,就没有必要一直挂着后台任务——服务会在 120 秒后以退出码 4 自行退出。此时应放弃该技能,退回到终端里 git diff / git show 展示 diff,或干脆直接开 PR 让评审在 PR 页面上进行。
启动评审服务器:完整命令与全部参数
技能的标准启动方式(在 Agent 会话中应以后台任务方式运行,如 Claude Code 的 run_in_background: true):
node .claude/skills/diff-review/server.mjs \
--repo <repo-root> \
--base HEAD \
--port 3000 \
--out /tmp/diff-review-comments.json
各参数含义(与 server.mjs 顶部的 Usage 注释一致):
| 参数 | 默认值 | 说明 |
|---|---|---|
--repo <path> |
process.cwd() |
仓库根目录。服务启动时先执行 git -C <path> rev-parse --show-toplevel 校验并解析真实根目录(见 server.mjs 的 git() / gitOrDie() 封装) |
--base <ref> |
HEAD |
diff 的基线。默认 HEAD 时评审的是未提交的工作:staged + unstaged + untracked 全部纳入 |
--committed |
关闭 | 评审已提交的分支工作(开 PR 前的典型用法)。此时 diff 的是 <base>..HEAD,且完全忽略工作树——未提交或未跟踪的本地编辑不会泄漏进评审 |
--port <n> |
3000 |
监听端口。端口被占用时可换一个端口并同步告知用户新 URL |
--out <file> |
tmpdir()/diff-review-<时间戳>.json |
评审结果 JSON 的落盘位置 |
--no-open |
关闭 | 不自动打开浏览器(Linux 无图形会话时服务本身也会跳过自动打开) |
--force(或环境变量 DIFF_REVIEW_FORCE=1) |
关闭 | 忽略"远程机器"探测,无限期等待用户打开页面——适用于用户明确说需要更多时间去配置端口转发的情况 |
两种 diff 模式的取舍(源自 SKILL.md):
- 默认模式(
--base HEAD):评审工作树相对HEAD的全部改动,包括未跟踪文件,适合"写完一段代码、提交前让人过目"; --committed模式:配合仓库默认分支的 merge base 使用,例如 ClickHouse 的 PR 惯例是直接指向master(见 .claude/CLAUDE.md 中"Every pull request must targetmasterdirectly"的规定),推荐组合是:
--base $(git merge-base origin/master HEAD) --committed
服务启动后的行为契约(SKILL.md "How to run" 与源码一致):
- 打印评审 URL(
http://localhost:3000),自动打开用户浏览器,保持存活直到用户点击 Finish review,然后以退出码 0 结束; - 在"看起来是远程"的机器上(SSH 会话、云实例、无图形会话的 Linux),照常启动,但会额外打印一条
ssh -L 3000:localhost:3000 <user>@<host>转发命令,且 120 秒内没有任何浏览器打开页面时以退出码 4 退出; - UI 完全自包含:渲染用的
@pierre/diffs库已随技能 vendored(见 vendor/README.md),运行不需要任何网络访问,任何第三方 CDN 都看不到正在评审的 diff——这一点不是性能考量,而是安全边界:diff 里包含尚未提交的本地改动。
Agent 侧协作流程与评审结果 JSON
SKILL.md 规定的 Agent 行为有两条"禁令"和一条"义务":
- 等待,不抢跑:把 URL(以及服务打印的端口转发命令,如有)转告用户后等待。不要自己 fetch 该 URL——对服务器而言,任何到达的请求都是"用户浏览器已打开"的证明,来自 Agent 的请求会破坏这个判断。也不要继续提交/开 PR——后台命令退出时(用户提交评审,或超时无人访问)你会被通知;
- 必须处理每一条评论:它们是用户的正式代码评审。对每条评论简要引用原文,然后修改代码或回答问题。修复后在提交前主动提出再跑一轮评审;
- verdict 决定能否提交:
verdict: "approve"且无评论 → 继续 commit / PR;verdict: "request_changes"→ 在完成修复且用户满意之前不得提交。
服务把评审结果写入 --out 文件,JSON 结构如下(SKILL.md 原文示例):
{
"verdict": "approve | "request_changes"",
"overall": "free-text overall comment (may be empty)",
"comments": [
{ "file": "src/app.ts", "side": "new", "startLine": 12, "endLine": 15,
"comment": "the user's comment text" }
]
}
其中 side: "new" 表示行号指向新版本文件;side: "old" 指向基线版本(即对删除行的评论)。从 server.mjs 的 /submit 处理逻辑看,实际落盘的 JSON 还会额外带上三个服务端字段:submittedAt(ISO 时间戳)、repo(解析后的仓库根目录)、base(评审基线),方便事后追溯这份评审对应哪次改动。
服务端实现深读:diff 是怎么收集的
server.mjs 的 collectFiles()(L132-L205)是整个技能的数据核心,实现细节比 SKILL.md 的描述更完整:
1. 变更文件清单来自 git 的机器可读输出。
--committed模式执行git diff --name-status -z <base> HEAD;- 默认模式执行
git diff --name-status -z <base>,再追加git ls-files --others --exclude-standard -z收集未跟踪文件(状态标记为?,后续统一按新增文件A处理); - 对重命名/复制(状态以
R或C开头)的记录会按-z的三段式格式解析出oldPath与newPath,保证重命名文件的 diff 正确。
2. 新旧内容分别从两个来源读取。
- 旧版本:
git show <base>:<path>(A/?状态没有旧版本,置空); - 新版本:
--committed模式取git show HEAD:<path>;默认模式直接读磁盘文件。这里有一个容易忽视的细节——符号链接不跟随:Git 把符号链接的 blob 存为链接目标路径字符串,源码特意用lstatSync+readlinkSync读链接本身而非目标内容,注释说明跟随链接"会给出错误的 diff,并且可能把无关的本地文件暴露进评审"(L174-L185); - 如果文件在 diff 与读取之间消失(比如评审期间又改了工作树),按已删除处理。
3. 两类文件会被跳过并在 UI 上提示。
- 二进制:任一新旧内容的前 8192 字节中出现 NUL 字节即判定为二进制(
looksBinary),跳过; - 过大:任一版本超过
MAX_FILE_BYTES = 2,000,000字节,跳过(L41)。
被跳过的文件会连同原因(binary 或 larger than 2000000 bytes)一起放进 /data 响应的 skipped 列表,评审页面顶部的黄色横幅会列出 "Not shown: ...",用户知道哪些改动没有出现在评审中。若没有任何可评审文件(含被跳过列表也为空),服务打印提示并以退出码 3 结束。
远程机器探测与 120 秒截止线
detectRemoteSignals()(L54-L97)实现了 SKILL.md "Exit code 4" 一条提到的三类信号,源码注释还解释了每条信号的取舍:
- SSH 会话:存在
SSH_CONNECTION/SSH_CLIENT/SSH_TTY任一环境变量。注释指出用户的浏览器通常在连接的另一端,只能通过转发端口到达 loopback; - 云实例:优先检查 cloud-init 状态文件(
/var/lib/cloud/instance或/run/cloud-init/instance-data.json,最便宜的可靠标记);不存在时再读 DMI 的sys_vendor/chassis_asset_tag/board_vendor,用正则匹配 Amazon EC2、Google、Microsoft、DigitalOcean、Hetzner、OpenStack、Alibaba Cloud、Oracle Cloud、Scaleway、Vultr 等厂商字符串。源码明确注释了故意不查 IMDS(169.254.169.254):那需要一次(IMDSv2 下两次)网络往返,在 link-local 路由被防火墙隔离时还会挂起,而 DMI 字符串零成本地给出同样的结论; - 无图形会话:仅 Linux 且
DISPLAY、WAYLAND_DISPLAY均未设置时触发(macOS 与 Windows 恒有窗口服务器,不适用)。
关键在于这些信号不直接导致拒绝,只决定两件事:是否打印端口转发提示、是否"布防"一个 120 秒的截止线(NO_VISITOR_WAIT_SECS = 120,L102)。真正"没人看"的证明只有一个可观测事实:120 秒内没有任何请求到达。源码注释举反例说明为什么信号本身不够:云桌面有自己的本地浏览器、ssh -X 会跨线打开浏览器、转发端口可以让任何机器触达 loopback——这些情况下页面其实是可达的。因此任何一个 HTTP 请求到达时,截止线定时器会被立即清除,之后无限期等待(L235-L241);而 Linux 上设置了 DISPLAY 的 ssh -X 会话甚至会被视为可以自动打开浏览器。
打印的转发命令也不是拍脑袋的模板。源码解析 SSH_CONNECTION(格式 <client_ip> <client_port> <server_ip> <server_port>)——这是用户自己的 ssh 实际连接的地址与端口,转发命令必须复用它;若非 22 端口则补上 -p;拿不到 SSH_CONNECTION 时宁可直接显示 <user>@<this-machine> 占位符也不装出"可复制即用"的样子,因为 hostname() 经常是用户机器无法解析的内网接口名(ip-172-31-…)(L361-L386)。
超时退出时(退出码 4) stderr 里同时给出两条退路建议:用 git diff / git show 在终端里看,或转发端口后加 --force 重跑以无限期等待。SKILL.md 强调这是无人值守隔离 VM 上的预期结果——应回退到终端 diff 继续工作,不要主动重试。
安全设计:loopback 绑定、会话 nonce 与零依赖 UI
这个技能面向的资产是"尚未提交的本地改动",源码里的安全决策都围绕这一点:
只监听 127.0.0.1。 server.listen(PORT, '127.0.0.1', ...)(L335)把服务钉死在回环地址上,远程可达性只能由用户主动建立的端口转发提供。
每次会话的 nonce。 服务生成 NONCE = randomBytes(16) 的十六进制串,注入到下发的 ui.html 中(替换占位符 __DIFF_REVIEW_NONCE__,见 ui.html L140)。POST /submit 要求请求头 x-diff-review-nonce 与之匹配,同时校验 Origin 只允许 http://localhost:<port> 与 http://127.0.0.1:<port>(L274-L287)。效果是:只有本服务器发出去的那个页面能完成评审——同机的其他标签页、跨源页面或对着 localhost 的盲目 POST 都会被 403 拒绝,无法替用户提交评审。
HTTP 面极小。 全部路由只有五个:GET /(或 /index.html,下发替换过 nonce 的 UI)、GET /data(diff 数据 JSON)、GET /vendor/...(vendored 依赖,见下)、GET /slow(仅测试用,模拟延迟响应,上限 60 秒)、POST /submit。其余一律 404。静态资源服务采用固定白名单 VENDOR_FILES(L224-L231):/vendor/pierre-diffs.mjs(gzip 存储,按请求的 Accept-Encoding 决定直接下发 gzip 还是运行时 gunzipSync 解压)加五个 node 内置模块的浏览器 polyfill(/node/process.mjs、buffer、events、tty、async_hooks——后者是因为 esm.sh 构建的 bundle 按绝对路径 import 这些 polyfill)。白名单之外的 vendor 路径一律不服务。
零依赖与 vendored 渲染库。 服务本身只用 Node 标准库(node:http、node:child_process、node:crypto、node:zlib、node:fs 等),没有任何 npm 依赖。前端 diff 渲染库 @pierre/diffs 1.2.12 以自包含 ESM bundle 形式 vendored(含内联的 oniguruma wasm),vendor/README.md 记录了每个文件的来源与 sha256,并说明 vendored 的动机:"a compromised CDN could otherwise exfiltrate the diff under review, which includes uncommitted local changes"——被攻破的 CDN 可以借此把含未提交改动的 diff 偷出去。该文件还给出了升级时的复验步骤(重新拉取、记录哈希、审计 import 面、更新 server.mjs 的 VENDOR_FILES 映射)。
评审 UI:逐行批注如何变成 JSON
ui.html 是单文件页面,核心逻辑:
- 通过
fetch('/data')拿到{repo, base, files, skipped},对每个文件用parseDiffFromFile(oldContents, newContents)在浏览器内计算 diff,交给 vendored 的CodeView渲染。支持 split/unified 视图切换、sticky 文件头、按行号悬停高亮、顶部文件跳转下拉框; - 行评论:点击行号旁出现的
+(gutter utility)或跨多行拖拽即可创建草稿评论;草稿态显示文本框(Ctrl+Enter保存、Esc丢弃),已保存的评论可 Edit / Delete,页面头部实时显示已保存评论数; - 提交:点击 Finish review 弹出提交对话框,显示"N 条行评论将回传给会话",可填写整体评论(overall),然后二选一——Approve(
verdict: "approve")或 Send comments(verdict: "request_changes");存在未保存草稿时会先确认是否丢弃; - 提交时 UI 把内部状态映射为 JSON:行号区间取
start/end,侧别把deletions映射为old、其余为new,与上文/submit的鉴权逻辑配合落盘(ui.html L391-L419); - 页面还内置了
?waitload=与?testannotation=两个测试钩子(延迟 load 事件、预置一条已保存评论和一条草稿),供无头截图工具验证渲染效果。
退出码速查与故障排查
汇总 SKILL.md "Troubleshooting" 一节与 server.mjs 中的 process.exit 调用点:
| 退出码 | 含义 | 处理建议 |
|---|---|---|
0 |
用户已提交评审,评论已写入 --out |
读取 JSON,逐条处理评论 |
1 |
启动错误,如端口被占用(EADDRINUSE)或 git 命令失败 |
端口占用通常是上一个残留服务:pkill -f "diff-review/server.mjs",或改用 --port <other> 并给用户新 URL |
3 |
相对所选 --base 没有可评审的改动 |
确认基线是否正确;若确实无改动则无需评审 |
4 |
机器"看起来是远程"(SSH 会话 / 云实例 / 无显示器的 Linux)且 120 秒内无浏览器访问 | 无人值守隔离 VM 上的预期结果:回退到终端 diff 继续,不要主动重试;用户确需更多时间时改用 --force(或 DIFF_REVIEW_FORCE=1)无限期等待 |
130 |
收到 SIGINT / SIGTERM(如用户想中止时 Agent 用 TaskStop 停掉后台任务) |
中止即结束,不要无限等待 |
几个实操要点重申:
- 端口 3000 被占:先
pkill -f "diff-review/server.mjs"清掉残留服务,或换端口;服务自己也会在 stderr 里打印这两条建议(L323-L333); - 用户想中止:直接停止后台任务即可,服务会打印
SIGTERM received, exiting without a review并以 130 退出; --force的正确用法:仅当用户主动提出"我需要更多时间转发端口"时才用,它会跳过全部远程信号探测,让服务无限期等待。
小结:这个技能在 ClickHouse 工作流中的位置
diff-review 与同目录下的 review 技能互补:后者让 Agent 按 ClickHouse 的评审标准(正确性、安全性、性能、changelog 模板合规等)审查 PR 或 diff;前者把人类的评审拉进同一工作流——Agent 的改动在离开本地之前先经过一次真实的人类逐行阅读。实现上它保持了与 ClickHouse 工程文化一致的克制:单一 .mjs 文件加一个 HTML 页面、零运行时依赖、静态资源白名单、会话级 nonce、可审计的 vendored 第三方 bundle(vendor/README.md 中逐文件记录 sha256),以及一套明确的退出码契约,使得"浏览器评审"这种依赖人机配合的流程在自动化环境里也能被可靠地启动、超时、回退和清理。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00