IDE 与 Bridge:一个 coding agent 怎么长出手脚
这是一份快照
本文的数字、常量、行数取自 2026-09-03 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档是干什么的
已有的研究文档(
claude-code/docs/chapter-16-ide-integration-bridge.md,1390 行)是 给已经懂的人看的:直接摆架构图、贴源码、说"这里用 BoundedUUIDSet 去重"。 它是资产,但它默认你已经知道 MCP 是什么、SSE 和 WebSocket 差在哪、lockfile 是什么协议。 第一次读会卡在第三段。这一份反过来:假设你没写过编辑器插件、没做过长连接、不知道 NAT 是什么, 从「为什么一个跑在终端里的程序需要和编辑器说话」开始,一层层往上搭, 直到能回答「给你一个 coding agent,你怎么让它接上 IDE,再让手机能远程操控它」。
它不是摘要。 摘要会把结论抽出来变成一句正确但没用的话("用云端中继而不是 NAT 穿透")。 本文的写法相反:每个结论都从「为什么另一个选项看起来更好」讲起—— 面试里能拉开差距的从来不是结论,是你能说清它的反面为什么诱人、以及你为它付了什么代价。
事实分级:本文两种可信度,请分开对待
这一节请先读,它决定你怎么引用本文的内容。
| 标记 | 含义 | 你可以怎么用 |
|---|---|---|
| 🔬 | 源码实读。我在 sid-code 仓库里打开文件、跑 grep 核过,带 file:line | 可以当事实引用,但请自己复跑一次(附录 A 给了命令) |
为什么要专门立这个标记:这类跨项目对比文档最常见的失真是 把二手转述说成第一手实读。一旦被追问"你在哪看到的",整份论证的可信度一起崩。 标清楚反而是加分项——它显示你知道自己知识的边界在哪。
关于具体数字的免责声明:文中所有代码行数、常量值("943 行"、"心跳 30 秒") 都是 2026-09-03 在某个 commit 上的快照。代码会变。引用它们是为了让你看见 "真实实现长什么样",不要把它们当恒定事实。
读法建议
| 你是谁 | 怎么读 |
|---|---|
| 完全零基础 | 第 0 章 → 第 1 章 → 第 2 章,先建立直觉。第 1 章不能跳,后面全靠它 |
| 会前端 / 写过插件 | 跳到第 3 章(Bridge)+ 第 4 章(传输层),那是纯分布式系统问题 |
| 想动手 | 第 2 章 + 第 8 章(sid-code 实测现状)+ 附录 A |
如果只有 30 分钟:读 §0.3(三层心智模型)、§2.2(lockfile)、§3.2(为什么选中继)、 第 9 章(陷阱)。这四块是骨架,其余都是它们的展开。
目录
| 章 | 主题 | 一句话 |
|---|---|---|
| 0 | 为什么需要这一层 | 三个场景 + 名词地图 + 三层心智模型 |
| 1 | 进程之间怎么说话 | 端口 / stdio / HTTP / WS / SSE,以及「谁连谁」这个最重要的直觉 |
| 2 | IDE 集成 | 把编辑器变成一个 MCP Server:lockfile 发现协议与三个能力 |
| 3 | Bridge | 让手机操控你本地的机器:NAT、中继、两代架构、权限代理 |
| 4 | 传输层 | 网络是不可靠的:重连、心跳、去重、有序批量 |
| 5 | LSP | 给 CLI 装上代码智能,以及为什么只做诊断 |
| 6 | remote-control | 独立 Bridge 服务器:多会话与 worktree 隔离 |
| 7 | Chrome 集成 | 为什么浏览器只能用 Native Messaging |
| 8 | sid-code 实测现状 | 带 file:line 的差距清单:哪些真跑得起来 |
| 9 | 陷阱库 | 十四个真实陷阱,每个都是「绿着坏掉」 |
| 10 | 横向对比 | 同一个能力,三种目的 |
| 12 | 术语表与学习路径 | 速查 |
| 附录 A | 可复跑命令 | 动手 |
第 0 章 · 为什么一个终端程序需要长出手脚
0.1 三个场景
场景一:信息鸿沟
你在 VS Code 里写代码,Claude Code 在另一个终端窗口里跑。
你选中了 30 行代码,想说"解释这段"。你怎么让它知道你选了什么? 复制粘贴。 30 行还行,300 行就很痛苦,而且粘贴进去它丢失了文件路径和行号信息。
反过来,它改了一个文件,在终端里打了一段 diff。你想看这个改动在项目上下文里的样子, 要切到 VS Code、找到那个文件、对着终端的 diff 逐行核对。
两个工具中间有一道墙。墙两边都知道同一件事的一半。
场景二:你不在电脑前
你在通勤,手机上想起一个 bug。你希望能说一句"帮我修一下 login 的那个报错", 让它在你家里那台开发机上跑起来——读代码、改文件、跑测试。
问题是:你家的机器在路由器后面,没有公网 IP。手机上的网页没法直接连上它。 这是一个真实的网络问题,不是权限问题。
场景三:它改错了但不知道
它改了一个 TypeScript 文件,把一个函数的参数类型从 string 改成了 number。 改完它说"完成了"。
但项目里有 7 个地方在调这个函数,全都传的是字符串。编译器会报 7 个类型错误。 IDE 里那 7 个文件会亮红波浪线——但 agent 看不到,因为它只有"读文件、写文件、跑命令"这几只手, 它没有"看见类型错误"这只眼睛。
它得等你说"你改坏了",或者它自己想起来去跑一次 tsc。
这三个场景对应三层能力,也就是本文的三条主线:
| 场景 | 缺的能力 | 这一层叫什么 |
|---|---|---|
| 信息鸿沟 | 和编辑器双向通信 | IDE 集成 |
| 你不在电脑前 | 让远端能驱动本地 | Bridge(远程控制) |
| 它改错了不知道 | 拿到编译器/类型系统的反馈 | LSP 集成 |
三层解决的是完全不同的问题,但它们共享一个设计哲学,这句话是本文的中心思想:
agent 进程是中心节点,所有外部系统都通过标准化协议接进来, 并且每一个都是「可选增强」——不接也能跑,接上更强。
这句话听起来像正确的废话。第 2 章会给你看它在代码里长什么样, 第 9 章会给你看违反它的代价。
0.2 名词地图:先把词认全
这一节是查表,不用背。后面每章第一次用到某个词时都会重新解释一遍, 这里放一份集中的,方便你读到一半回来查。
按「从近到远」排列——从跑在你机器上的进程,一路排到云端。
0.2.1 编辑器侧
| 词 | 中文 | 是什么 |
|---|---|---|
| IDE | 集成开发环境 | VS Code、Cursor、IntelliJ 这类编辑器。本文里它是一个独立进程 |
| Extension / Plugin | 扩展 / 插件 | 跑在 IDE 进程里的一段代码。它能拿到"用户选了哪几行"这类只有 IDE 知道的信息 |
| workspace folder | 工作区目录 | IDE 当前打开的那个(或那几个)文件夹 |
| selection | 选区 | 用户鼠标拖选的那段文本,含起止行号和列号 |
| diff view | 差异视图 | 编辑器里左右分栏对比新旧内容的那个界面 |
| @mention | @提及 | 用户在界面里点一下"把这个文件/这段代码给 agent" |
0.2.2 通信机制侧(第 1 章详解,这里只认脸)
| 词 | 中文 | 一句话 |
|---|---|---|
| IPC | 进程间通信 | 两个进程怎么互相说话的统称 |
| stdio | 标准输入输出 | 最古老的方式:父进程往子进程的"嘴"里塞字节,从"耳朵"里读 |
| port / 端口 | 端口 | 一台机器上的门牌号(0-65535)。进程占一个端口,别人按门牌找它 |
| HTTP | — | 一问一答:你发一个请求,它回一个响应,然后连接就结束了 |
| WebSocket(WS) | — | 一条长期打开的双向管道。两边随时可以主动说话 |
| SSE | 服务端推送事件 | 单向长连接:服务端可以一直往下推,客户端只能听。本质是一个不结束的 HTTP 响应 |
| JSON-RPC | — | 一种约定:"我发 {id:1, method:"foo", params:{...}},你回 {id:1, result:...}" |
| RPC | 远程过程调用 | 让"调用另一个进程里的函数"看起来像调用本地函数 |
| lockfile | 锁文件 | 一个放在约定目录里的小文件,内容是"我在这儿,端口是 X"。用文件系统当服务发现 |
0.2.3 协议与标准侧
| 词 | 中文 | 是什么 | 注意 |
|---|---|---|---|
| MCP | 模型上下文协议 | Anthropic 定的标准,用来让 agent 接外部工具/数据源。底层是 JSON-RPC | 本文里 IDE 扩展被当成一个 MCP Server 接进来 |
| MCP Server | — | 提供能力的那一侧 | 反直觉:IDE 是 Server,agent 是 Client |
| MCP Client | — | 消费能力的那一侧 | agent 进程扮演这个 |
| LSP | 语言服务器协议 | 微软定的标准,让编辑器和"懂某种语言的程序"通信 | 它和 MCP 没有关系,是两套独立协议 |
| Language Server | 语言服务器 | 真正懂语言的那个进程:tsserver(TS)、pyright(Python)、rust-analyzer | 通常是子进程 + stdio |
| diagnostics | 诊断 | LSP 的术语,指"错误、警告、提示"这些红黄波浪线 |
0.2.4 网络与远程侧
| 词 | 中文 | 是什么 |
|---|---|---|
| NAT | 网络地址转换 | 你家路由器干的事:内网很多设备共用一个公网 IP。后果是外面主动连不进来 |
| NAT 穿透 | — | 一堆技巧(STUN/TURN/打洞)让外网能连进内网 |
| Relay / 中继 | 中继 | 换个思路:内网机器主动连出去到一台有公网 IP 的服务器,消息都过这台服务器转 |
| Bridge | 桥 | 本文特指「让远端(网页/手机)能驱动本地 agent」的那一整套机制 |
| JWT | — | 一段自带签名的令牌字符串,服务端不查库就能验真伪 |
| heartbeat / keep-alive | 心跳 / 保活 | 定期发一个没内容的包,告诉对方"我还活着",同时防止中间设备把闲置连接掐掉 |
| backoff | 退避 | 重试失败后越等越久(1s → 2s → 4s…),避免把服务端打死 |
| idempotent / 去重 | 幂等 / 去重 | 同一条消息收到两次,第二次要能识别出来并丢掉 |
0.2.5 ⚠️ 三个一词多义,先分清再往下读
这三个词在本文里会反复出现,同一个词在不同段落指不同东西。不前置分类的话第 3 章会读崩。
① "Bridge" 有三个所指:
| 所指 | 是什么 |
|---|---|
| Bridge 协议 | 本地和云端之间那套消息格式 |
| Bridge 模式 | agent 进程的一种运行形态(和 TUI 模式、无头模式并列) |
remote-control 独立服务器 | 一个常驻进程,自己派生子进程处理多个会话(第 6 章) |
② "Server / Client" 的方向在两层里是反的:
| 层 | 谁是 Server | 谁是 Client |
|---|---|---|
| IDE 集成 | IDE 扩展(它监听端口) | agent 进程 |
| Bridge | 云端中继 | agent 进程 |
所以 agent 进程在两层里都是 Client——它总是主动连出去的那一方。 这不是巧合,第 1 章 §1.4 会讲为什么必须这样。
③ "diff" 既是名词也是动作: "计算一个 diff"(数据)vs "在 IDE 里展示 diff"(打开一个界面并等用户操作)。 第 2 章 §2.5 讲的是后者,它是阻塞的、有返回值的——这是最容易被低估的一点。
0.3 三层心智模型
后面所有内容都可以挂在这张图上。建议看懂它再往下读。
┌─────────────────────┐
远(云端) │ 云端中继服务器 │ ← 有公网 IP
│ (转发消息 / 认证) │
└──────────▲──────────┘
│ agent 主动连出去
│ (WS 或 SSE + HTTP POST)
─────────────────────────────────┼─────────────────────────────
│
近(你的机器) ┌──────────┴──────────┐
│ │
┌─────────────────│ agent 进程 │─────────────────┐
│ │ (中心节点) │ │
│ └──────────┬──────────┘ │
│ 作为 MCP Client │ 作为 LSP Client │
│ 连出去 │ 派生子进程 │
▼ ▼ ▼
┌───────────┐ ┌──────────────┐ ┌──────────────┐
│ IDE 扩展 │ │ tsserver │ │ Chrome │
│ (MCP │ │ pyright │ │ Native Host │
│ Server) │ │ (子进程/stdio)│ │ (子进程/stdio)│
└───────────┘ └──────────────┘ └──────────────┘
第 2 章 第 5 章 第 7 章三条边,三种关系,记住这个表就抓住了整章的骨架:
| 边 | agent 的角色 | 谁先启动 | 连接方向 | 协议 |
|---|---|---|---|---|
| agent ↔ IDE | MCP Client | IDE 先(它开端口等着) | agent → IDE | MCP over WS/SSE |
| agent ↔ 云端 | Client | 云端一直在 | agent → 云端 | 自定义 Bridge 协议 |
| agent ↔ 语言服务器 | LSP Client + 父进程 | agent 先(它派生子进程) | 父 → 子(stdio) | LSP over JSON-RPC |
第三行和前两行有个本质区别:语言服务器是 agent 自己生的孩子,它管孩子的生死。 前两行的对端都是"别人的进程",agent 只能连、不能管。
这个区别决定了三层的容错策略完全不同(第 5 章 §5.6 会展开):
- 孩子死了 → 我可以重启它
- 别人的进程断了 → 我只能重连,或者放弃这个增强功能继续跑
0.4 本章自检
读完这一章,你应该能回答:
- 为什么 agent 在 IDE 集成和 Bridge 两层里都是 Client?(提示:§0.2.5 ②,答案在 §1.4)
- "IDE 是 MCP Server" 这句话反直觉在哪?
- 语言服务器和 IDE 扩展,agent 对它们的容错策略为什么不一样?
- 场景二(手机远程操控)的核心障碍是权限问题还是网络问题?
答不上第 1 和第 4 题不用担心——那正是第 1 章要讲的。
第 1 章 · 两个进程怎么说话
这一章不能跳。 后面三层(IDE / Bridge / LSP)的每一个设计决策, 追到底都是这一章里的某个权衡。如果你已经很熟 WS/SSE 的区别, 至少读一下 §1.4 和 §1.6——那两节讲的东西在一般教程里不会讲。
1.1 先明确问题:进程之间是隔绝的
一个进程的内存,另一个进程碰不到。这是操作系统的基本保护,不是设计缺陷。
所以 agent 进程想知道"用户在 VS Code 里选了哪几行",没有任何办法直接读到。 它必须让 VS Code 那边的代码主动把这个信息送出来,中间要经过一条两个进程都能碰到的通道。
操作系统提供的通道就那么几种。下面按"从简单到复杂"排,每种都说清它天然适合什么。
1.2 通道一:stdio(父子进程之间)
如果 A 进程是 B 进程启动的(B 是 A 的子进程),它们天然就有三根管子:
父进程 A 子进程 B
┌────────┐ ┌────────┐
│ │ ──── stdin ───────▶ │ │ A 往 B 嘴里塞字节
│ │ ◀─── stdout ─────── │ │ A 从 B 嘴里听字节
│ │ ◀─── stderr ─────── │ │ B 的报错单独一根管
└────────┘ └────────┘这就是你平时 ls | grep foo 里那个 | 干的事。
优点:不需要端口、不需要网络、不需要服务发现。子进程一起来就通了, 而且子进程死了父进程立刻知道(拿到退出码)。
缺点:只能父子之间用。两个互不相干的进程没法用 stdio 说话。
本文里谁用它:
- 🔬 LSP —— agent 派生
tsserver子进程,走 stdio(见packages/core/src/lsp/client.ts) - 📄 Chrome Native Messaging —— 浏览器扩展只能这么和本地进程说话(第 7 章)
一个必须知道的细节:stdio 是字节流,不是消息流。你往里写 {"a":1}{"b":2},对端读到的可能是 {"a":1}{"b 加上稍后的 ":2}。 所以所有基于 stdio 的协议都要自己划边界。LSP 的做法是加一个长度头:
Content-Length: 42\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"initialize"}先读头知道后面有 42 字节,再读 42 字节。这叫 framing(分帧)。 📄 Chrome Native Messaging 用的是 4 字节二进制长度前缀,思路一样。
🎯 面试点:被问"你怎么和语言服务器通信",答"stdio + JSON-RPC"是对的, 但补一句"要自己处理 Content-Length 分帧,因为 stdio 是流不是消息", 立刻显示你真写过。
1.3 通道二:端口(任意两个进程之间)
两个没有亲缘关系的进程要说话,最通用的办法是其中一个占一个端口。
端口就是一台机器上的门牌号,0-65535。一个进程可以说"我在 12345 号门等着"(这叫 listen), 另一个进程说"我要连 127.0.0.1 的 12345 号门"(这叫 connect)。
进程 A (监听方 / Server) 进程 B (连接方 / Client)
┌────────────────────┐ ┌──────────────────┐
│ listen(12345) │ ◀──────── │ connect( │
│ 我在这儿等 │ 握手 │ 127.0.0.1:12345)│
└────────────────────┘ └──────────────────┘127.0.0.1(也写作 localhost)是"这台机器自己"的地址, 所以这种连接不出网卡,别的机器碰不到。这一点很重要,第 2 章会用到它做安全边界。
这里出现了本文最重要的一个不对称:
监听的那一方必须先启动,而且连接方必须知道端口号。
"必须知道端口号"这个要求,看起来微不足道,但它是第 2 章整节的由来。 IDE 每次启动占的端口是随机的(它不能写死一个,不然开两个窗口就撞了)。 agent 进程怎么知道这次是哪个端口?——这就是 §2.2 的 lockfile 协议要解决的问题。
1.4 ★ 关键直觉:谁连谁,由「谁能被找到」决定
这一节是本章的核心,也是回答 §0.4 第 1 题的地方。
网络连接天生不对称:有一方监听、一方连接。选谁当监听方,不是随便定的, 判据只有一条:
能被对方稳定找到的那一方,当监听方。
拿三层各自套一遍:
| 场景 | 谁能被稳定找到 | 所以谁监听 | agent 的角色 |
|---|---|---|---|
| agent ↔ IDE | IDE 先起、且能写 lockfile 公布自己 | IDE | Client(连出去) |
| agent ↔ 云端 | 云端有固定域名和公网 IP | 云端 | Client(连出去) |
| agent ↔ 语言服务器 | 都在同一台机器,且是 agent 生的孩子 | 用 stdio,无监听方 | 父进程 |
第二行是整个 Bridge 章节的地基。你家的机器没有稳定地址(NAT 后面、IP 会变、 可能在咖啡店 WiFi 上),所以它不可能当监听方。云端有固定域名,所以云端监听。
推论——这句话请记住,它是第 3 章的一句话总结:
本地机器永远是主动连出去的那一方。「远程控制」这个说法从网络层面看是反的: 不是云端连过来控制你,是你的机器连上去领活干。
面试里如果被问"claude.ai 怎么控制我本地的 Claude Code", 上面这句话就是满分答案的第一句。
1.5 通道三到五:HTTP / WebSocket / SSE
有了端口,还要决定"在这条连接上按什么规矩说话"。三种主流选择:
HTTP:一问一答,然后散场
Client ──── 请求 ────▶ Server
Client ◀─── 响应 ───── Server
(连接结束)特点:最简单,代理/防火墙/公司网关全都认它。
致命限制:Server 不能主动说话。 它只能在你问的时候回答。 所以"IDE 里用户改了选区,主动告诉 agent"这件事,纯 HTTP 做不到。
传统的绕法是轮询(polling):Client 每秒问一次"有新消息吗"。 能用,但延迟高(平均半个轮询周期)而且浪费——99% 的请求得到的答案是"没有"。
WebSocket:一条长期打开的双向管道
Client ──── 升级握手 ───▶ Server
Client ◀══════════════▶ Server 连接保持打开
两边随时主动发消息特点:真双向、低延迟、有消息边界(不用自己分帧,协议帮你做了)。
代价(这三条是选它的真实成本,面试常问):
- 企业代理经常不支持。WebSocket 需要 HTTP 升级握手(
Upgrade: websocket), 有些老代理会把这个头剥掉或直接拒绝。 - 断线要自己全部处理。重连、退避、断线期间的消息怎么办,全是你的活。
- 闲置会被中间设备掐掉。很多代理/负载均衡看到一条连接 60 秒没数据就回收它—— 记忆里有一条真实事故正是这个(
claude-code-502-is-proxy-60s-idle-timeout)。 所以必须发心跳。
SSE:单向长连接,但自带断线续传
SSE(Server-Sent Events)本质上是一个永远不结束的 HTTP 响应。 Server 保持连接打开,一行一行往下写:
Client ──── GET /events ───▶ Server
Client ◀─── data: {...} ── Server ← 一直推
◀─── id: 42 Server
◀─── data: {...} ── Server它只能 Server → Client 单向。 那 Client 想说话怎么办? 另外发一个普通 HTTP POST。 读一条 SSE 长连接,写用短请求——这叫混合模式, 第 4 章会看到这正是新一代 Bridge 的选择。
SSE 的杀手级特性(这是它相对 WebSocket 的真正优势,不是"更简单"):
每个事件可以带一个 id:。断线重连时,浏览器/客户端自动带上 Last-Event-ID: 42 这个请求头,Server 就知道"从 43 开始重发"。
断线续传是 SSE 协议的标准特性,不是你实现的功能。 WebSocket 里你得自己维护消息游标、自己实现重放逻辑、自己处理边界情况。
三者对照表
| HTTP | WebSocket | SSE | |
|---|---|---|---|
| 方向 | 请求-响应 | 双向 | Server → Client 单向 |
| 长连接 | 否 | 是 | 是 |
| 代理兼容性 | 最好 | 一般(需要升级握手) | 好(就是普通 HTTP 响应) |
| 断线续传 | 不适用 | 自己实现 | 协议内置(Last-Event-ID) |
| 消息边界 | 天然有 | 协议提供 | 按 \n\n 分隔 |
| 需要心跳 | 不需要 | 需要 | 需要 |
| 错误信号 | HTTP 状态码 | WS close code(较隐晦) | HTTP 状态码(401/403/404 一目了然) |
📄 chapter-16 记载 Claude Code 有三套传输实现,恰好对应这张表的演化路径: 纯 WebSocket → WS 读 + HTTP POST 写 → SSE 读 + HTTP POST 写。 第 4 章 §4.2 会讲这三代各自解决了上一代的什么问题。
1.6 在通道之上:为什么还需要「协议」
有了通道,还得约定消息格式。最常用的是 JSON-RPC:
// 请求
{"jsonrpc":"2.0","id":1,"method":"openDiff","params":{"filePath":"/a.ts"}}
// 响应
{"jsonrpc":"2.0","id":1,"result":{"status":"FILE_SAVED"}}
// 通知(没有 id,因为不需要回复)
{"jsonrpc":"2.0","method":"selection_changed","params":{"start":{"line":3}}}三件事值得注意,它们都会在后面变成陷阱:
① id 是用来配对的。 你同时发 5 个请求,响应可能乱序回来。 靠 id 才知道哪个响应对应哪个请求。
② 没有 id 的叫「通知」(notification),它是单向的、不需要回复的。 IDE 告诉 agent"用户改了选区"就是通知——agent 不需要回应什么。
⚠️ 这里埋着本文最典型的一个陷阱:通知的
method名字是纯字符串约定。 一方发selection_changed,另一方订阅notifications/selection_changed, 两边都不会报错,消息只是永远匹配不上。 🔬 sid-code 现在就踩在这个坑里(§8.2 有file:line证据,§9.1 讲为什么它这么难发现)。
③ MCP 和 LSP 都是「JSON-RPC + 一套预定义的 method 名」。 所谓"协议标准",很大程度上就是"我们约定好 method 叫什么、params 长什么样"。 理解这一点,MCP 和 LSP 就没有神秘感了:
| 传输 | 底层格式 | 谁定的 | 典型 method | |
|---|---|---|---|---|
| MCP | stdio / SSE / WS | JSON-RPC | Anthropic | tools/list、tools/call |
| LSP | 通常 stdio | JSON-RPC | 微软 | textDocument/definition、textDocument/publishDiagnostics |
1.7 一个必须理解的取舍:复用协议 vs 自己造
IDE 集成需要的能力有:连接管理、能力发现(对方支持什么)、调用(打开 diff)、 通知(选区变了)、断线处理。
你有两个选择:
| 复用 MCP | 自己造一套 IPC 协议 | |
|---|---|---|
| 开发量 | 几乎为零(基础设施已有) | 从零写连接管理、重连、序列化 |
| 一致性 | IDE 和别的 MCP server 走同一套代码路径 | 两套代码路径,两处 bug |
| 灵活性 | 受 MCP 的形状约束 | 想怎么设计怎么设计 |
| 调试 | 现有的 MCP 调试工具直接可用 | 得自己做工具 |
📄 Claude Code 选了复用 MCP,理由是"零成本复用已有基础设施"。 🔬 sid-code 也照着做了(packages/core/src/ide/integration.ts:99 直接调 mcpManager.addServer)。
但"受 MCP 形状约束"这个代价是真实存在的,而且它就是 §1.6 那个陷阱的根源:
IDE 的通知(selection_changed)不是 MCP 标准通知,它是 IDE 扩展的私有约定。 你把 IDE 塞进 MCP 的框架里,就得在一个「为标准通知设计的路由器」上 处理「非标准通知」。sid-code 的路由器直接把 notification.method 当 key 用 (🔬 packages/core/src/mcp/client.ts:141),于是订阅时多写的 notifications/ 前缀 成了永久失配。
🎯 这是一个很好的面试回答素材:被问"复用协议有什么代价", 具体到"当被接入方有协议外的私有扩展时,你会在一个不为它设计的抽象上做适配, 而失配是静默的"——比空谈"灵活性下降"有说服力得多。
1.8 本章自检
- 为什么 stdio 需要自己分帧,WebSocket 不需要?
- SSE 相对 WebSocket 的真正优势是什么?(答"更简单"只得一半分)
- "谁监听谁连接"的判据是什么?为什么本地机器不能当监听方?
- JSON-RPC 的"通知"和"请求"差在哪?为什么通知的失配是静默的?
- 复用 MCP 的代价是什么?举一个具体的失效形态。
第 2 章 · IDE 集成:把编辑器变成一个 MCP Server
本章解决 §0.1 场景一(信息鸿沟)。读完你应该能自己设计一套 IDE 发现协议, 并且知道每个字段为什么在那里。
2.1 先想清楚:到底要传什么
在设计协议之前,先列清单。IDE 集成的通信需求是双向的,而且两个方向性质完全不同:
Claude → IDE(我要你做事):
| 我想要 | 性质 |
|---|---|
| 打开一个文件 | 发出去就完事(fire-and-forget) |
| 展示一个 diff,等用户决定 | 阻塞、有返回值、可能等几分钟 |
| 关掉所有 diff 标签页 | 发出去就完事 |
IDE → Claude(有事发生了):
| 发生了什么 | 性质 |
|---|---|
| 用户改了选区 | 通知,高频(拖鼠标会连续触发) |
| 用户 @ 了一段代码 | 通知,低频 |
| 用户点了"发送到 Claude" | 通知 |
看这两张表,第一个方向是调用(call),第二个方向是通知(notification)。 这正好是 JSON-RPC 的两种消息形态(§1.6),也正好是 MCP 已经提供的两种原语: tools(工具调用)+ notifications(通知)。
这就是「IDE 是 MCP Server」的全部理由:需求的形状和 MCP 的形状对上了。 不是因为 MCP 高级,是因为不用改需求就能塞进去。
2.2 ★ 核心问题:怎么找到 IDE
这是本章最值得学的一节,因为它是一个用最笨的办法解决服务发现的漂亮案例。
问题重述
§1.3 讲过:连接方必须知道端口号。但:
- IDE 每次启动占的端口是随机的(写死会撞车:你开两个 VS Code 窗口就完了)
- 你可能同时开着 VS Code 和 Cursor 和 IntelliJ
- agent 进程和 IDE 谁先启动都可能(你可能先开终端,也可能先开编辑器)
那 agent 怎么知道该连 127.0.0.1: 后面填几?
三个候选方案
| 方案 | 怎么做 | 为什么不选 |
|---|---|---|
| 固定端口 | 约定 IDE 一定用 45678 | 多窗口就撞车。而且端口可能被别的程序占了 |
| 扫描进程 | ps aux | grep Code | 能知道"有个 VS Code 在跑",但不知道它监听哪个端口、打开了哪些目录。信息不够 |
| 扫端口 | 从 1024 试到 65535 | 慢、吵、会误连别人的服务。而且连上了也不知道是不是 IDE |
| lockfile ✅ | IDE 启动时在约定目录写一个文件,说明自己在哪 | —— |
选 lockfile 的核心理由是信息量:进程扫描只能给你"存在性", lockfile 是IDE 自己主动申报的结构化信息,想放什么就能放什么。
lockfile 长什么样
🔬 sid-code 的实现(packages/core/src/ide/lockfile.ts,91 行):
~/.sid-code/ide/
├── 12345.lock ← 端口写在文件名里
├── 23456.lock
└── 34567.lock文件名就是端口号(12345.lock → 端口 12345)。内容是 JSON (🔬 packages/core/src/ide/types.ts:7-20):
interface IDELockfileContent {
workspaceFolders?: string[] // IDE 打开的目录
pid?: number // IDE 进程 PID
ideName?: string // "VS Code" / "Cursor"
transport?: "ws" | "sse" // 用哪种传输
runningInWindows?: boolean // WSL 场景标记
authToken?: string // 认证令牌
}逐字段问「为什么它在这里」——这是设计协议时该有的思维方式:
| 字段 | 为什么需要 | 没有它会怎样 |
|---|---|---|
| 端口(在文件名里) | 连接的必要信息 | 连不上 |
workspaceFolders | 消歧:你可能开了 3 个 IDE 窗口,只有一个打开了当前项目 | 连错窗口,选区来自别的项目 |
pid | 判活:IDE 崩溃时不会删自己的 lockfile | 一直尝试连一个死进程 |
ideName | 显示用;也用于决定"能不能自动装扩展" | UI 上只能显示"某个 IDE" |
transport | agent 要知道用 ws:// 还是 http:// 去连 | 协议对不上,握手失败 |
runningInWindows | WSL 场景:agent 在 Linux 里,IDE 在 Windows 上,路径格式和 IP 都不一样 | WSL 用户连不上(§9.5) |
authToken | 防止本机上任意程序连上你的 IDE 乱发命令 | 本机提权面(下面详述) |
为什么需要 authToken:一个容易被忽略的安全边界
有人会说:127.0.0.1 别的机器连不进来,还要 token 干什么?
因为 127.0.0.1 的保护边界是「机器」,不是「进程」。你机器上任何一个程序 ——包括你 npm install 装进来的某个包的 postinstall 脚本——都能连 127.0.0.1:12345,然后让你的 IDE 打开任意文件、展示任意 diff。
authToken 把边界从"机器级"收紧到"能读到 lockfile 的进程"。 而 lockfile 在 ~/.sid-code/ide/ 下,靠文件权限保护。
🎯 面试加分点:讲 lockfile 时主动提这一层。很多人会答"本机通信不需要认证", 这是错的——本机通信的攻击面是「本机上的其他程序」,这个面在供应链攻击时代很大。
发现流程:为什么要轮询 30 秒
🔬 packages/core/src/ide/detect.ts:45-68:
findAvailableIDE(cwd, timeoutMs = 30_000)
│
├─ 循环,每秒一次,最多 30 秒
│ │
│ ├─ cleanupStaleLockfiles() ← 删掉 PID 已死的 lockfile
│ ├─ 读所有 .lock,按 mtime 排序(最新优先)
│ ├─ 逐个匹配:
│ │ ① 环境变量端口精确匹配(SID_CODE_SSE_PORT)
│ │ ② 工作区目录匹配(cwd 是 workspaceFolders 之一的子路径?)
│ │
│ ├─ 恰好 1 个匹配 → 返回它 ✅
│ ├─ 多于 1 个 → 返回 null(交给用户手动选)
│ └─ 0 个 → 等 1 秒,重试
│
└─ 超时 → 返回 null为什么轮询而不是一次就放弃? 因为启动顺序不确定。你可能先开终端跑 agent, 过 10 秒才打开 VS Code。一次性检查会永远失败。30 秒窗口覆盖了"人类在几秒内切窗口"这个场景。
为什么按 mtime 排序(最新优先)? 因为最近启动的 IDE 窗口最可能是你正在用的那个。 这是一个启发式,不保证正确,但成本为零。
为什么"多个匹配 → 放弃"而不是猜一个? 这里有一个值得单独讲的判断:当消歧信息不足时,猜错的代价高于让用户多点一下。 连错 IDE 窗口的后果是「选区同步来自另一个项目」——用户会看到 agent 引用完全不相干的代码, 而且极难归因(一切看起来都正常工作,只是内容是错的)。 所以退回手动选择(/ide 命令)是对的。
📄 Claude Code 在这里多做了一层:进程祖先链检查。如果 agent 跑在 IDE 的内置终端里, 那个 IDE 的 PID 一定在 agent 进程的祖先链上(agent 的父进程的父进程…之一是它)。 用这个能在多窗口重叠时消歧。
📄 而且 chapter-16 记了一个性能教训很值得学:这个祖先链检查要 shell 出去调多次 ps, 最早它被放在工作区匹配之前,于是每秒轮询 × 每个 lockfile 都调一遍 ps, 在 CPU profile 里成了主导项。改成放在工作区匹配之后,大多数 lockfile 在目录检查阶段 就被过滤掉了,祖先链检查几乎不触发。
🎯 这个例子的一般化教训:在"便宜的过滤"和"昂贵的过滤"之间,顺序决定性能。 面试里讲优化,这种"没改算法只改顺序"的例子比"加了缓存"有说服力。
🔬 sid-code 没有祖先链检查(detect.ts 只有环境变量 + 工作区两条), 所以多窗口重叠时直接甩给用户手动选。这是一个已知的健壮性差距,见 §8.2。
2.3 连上之后:注册成一个动态 MCP Server
发现完成后,agent 把 IDE 注册成一个 MCP Server。🔬 packages/core/src/ide/integration.ts:82-118:
async connectToIDE(ide: DetectedIDE): Promise<boolean> {
const transport = ide.url.startsWith("ws") ? "ws" : "sse";
const config: MCPServerConfig = {
transport, url: ide.url, authToken: ide.authToken,
ideName: ide.name,
ideRunningInWindows: ide.ideRunningInWindows,
scope: "dynamic", // ← 注意这个
};
await this.mcpManager.addServer(IDE_SERVER_NAME, config); // IDE_SERVER_NAME = "ide"
// ...
this.selection.register(client); // 注册选区通知处理器
this.mentions.register(client); // 注册 @提及通知处理器
}三个细节:
① scope: "dynamic" —— 区别于用户在配置文件里写死的 MCP server。 "dynamic" 意味着这个 server 是运行时发现的、不写盘、进程退出就没了。 如果不区分,IDE 连接会污染用户的持久配置。
② 固定名字 "ide" —— 整个进程只允许有一个 IDE 连接。 这是刻意的:多个 IDE 同时连上时,"当前选区"这个概念就没有唯一答案了。
③ 通知处理器在连接成功后才注册 —— 顺序不能反。这一点看起来显然, 但记忆里有一条真实事故正是反的(subagent-hook-wiring-order-rootcause: 注册早于系统初始化 → 拿到的引用恒为 undefined)。
2.4 能力一:选区同步
用户在 IDE 里拖选一段代码,扩展发一个通知过来。 🔬 packages/core/src/ide/selection.ts:46-73:
client.onNotification("notifications/selection_changed", (params) => {
const { start, end } = p.selection;
let lineCount = end.line - start.line + 1;
// 如果光标落在行首(character === 0),不计入该行
if (end.character === 0 && lineCount > 1) lineCount--;
this.currentSelection = { filePath, text, lineStart: start.line, lineCount, ... };
});那个 character === 0 的判断值得单独讲。 它是一个交互直觉的编码:
你在编辑器里从第 3 行开头拖到第 5 行开头,编辑器报的是 start.line=3, end.line=5。 按公式算是 3 行(3、4、5)。但你视觉上只选中了 2 行——第 5 行是空的, 光标停在它的最左边,那一行的字符一个都没被高亮。
所以要减 1。这行代码在修的不是 bug,是**"编辑器的坐标语义"和"人眼看到的选区"之间的差**。
🎯 这类代码是面试里的好素材:它显示你理解规格和体感的差异。 一句"因为 end 是 exclusive 而人眼是 inclusive"就能讲明白。
2.5 能力二:Diff 展示(本章最微妙的一块)
这是 IDE 集成里最有价值也最容易做错的能力。先看它想干什么:
Claude 准备改 a.ts
│
├─ 在 IDE 里打开一个 diff 标签页(左:原内容,右:新内容)
│
├─ ⏸ 等用户操作 ────┐
│ ├─ 用户直接保存 → 接受
│ ├─ 用户在 diff 里又手动改了几处,再保存 → 接受「用户改过的版本」
│ ├─ 用户关掉标签页 → 拒绝
│ └─ 用户点拒绝按钮 → 拒绝
│
└─ 按用户的决定继续关键在第三个分支:用户可能在 diff 视图里继续编辑,然后保存。 此时该落盘的不是 Claude 生成的内容,而是用户手改后的内容。
这让 diff 展示从"一个展示功能"变成了一个权限确认 + 内容协商机制。 它不是 fire-and-forget 的通知,它是一个阻塞的、有返回值的调用。
🔬 sid-code 的返回类型(packages/core/src/ide/diff.ts:15-21)把四种结果都建模了:
type DiffResult =
| { action: "saved"; content?: string } // 保存(content 可能是用户改过的)
| { action: "rejected" } // 拒绝
| { action: "closed" } // 关掉了标签页
| { action: "unsupported" } // IDE 没连 / 不支持
| { action: "error"; message: string }unsupported 这个分支是"可选增强"哲学的具体体现:IDE 没连上不是错误, 是一个正常的返回值,调用方拿到它就走原来的终端 diff 流程。
⚠️ 🔬 sid-code 的 diff 能力目前是死代码:
showDiffInIDE只被ide/tool-hooks.ts引用,而tool-hooks.ts全仓零 import (grep 实证见 §8.2)。它连编辑工具都没接上——写好了、测过了、没接线。 这个失效形态在第 9 章有专门一节(§9.2),因为它是本仓反复出现的模式。
2.6 能力三:@提及
用户在 IDE 里选中代码,点一下"发给 Claude"。扩展发 at_mentioned 通知, 带上文件路径和行范围。agent 记下来,在下一轮对话里作为上下文。
🔬 packages/core/src/ide/mention.ts:46。机制上和选区同步一样, 差别只在语义:选区是"用户现在在看什么"(会不停变), @提及是"用户明确要求把这个纳入上下文"(是一个事件,要留存)。
这个区分很重要:选区可以被下一次选区覆盖,@提及不能被覆盖,得攒起来。
2.7 自动连接的判定:为什么是一条「或」链
不是所有场合都该自动连 IDE。自动连接有副作用:起一个 MCP 连接、注册处理器、 可能触发扩展安装。对一个纯终端用户,这些都是白花的开销。
📄 Claude Code 的判定是一条或链(配置开启 / --ide 参数 / 在 IDE 内置终端里 / 环境变量指定了端口 / 正在装扩展 / 环境变量强制开启),再 AND 一个"环境变量没强制关闭"。
这个设计的一般化形态是:
有副作用的自动行为,默认关闭,但接受多种「用户显然在这个场景里」的信号。 并且留一个能强制关掉的开关。
最有意思的信号是"在 IDE 内置终端里运行"。IDE 会给它开的终端设环境变量 (比如 TERM_PROGRAM=vscode),检测到它 = 用户几乎必然想连。 这是一个零成本的意图推断。
2.8 扩展自动安装:一个务实的不对称
📄 Claude Code 会自动装自己的 IDE 扩展:调 IDE 的 CLI (code --install-extension anthropic.claude-code),并且比较版本号,旧了就更新。
但 JetBrains 系不自动装,只能引导用户去 Marketplace 手动下。 理由是 JetBrains 的插件安装不支持从 CLI 完成。
这是个好例子:能力矩阵里的空格不总是"还没做",有时是"平台不允许"。 面试里被问"为什么 A 支持 B 不支持",能区分这两种原因是加分的。
🔬 sid-code 的 extension-install.ts(70 行)只覆盖 vscode / cursor / windsurf, 没有 JetBrains。但这里有个更根本的前提:sid-code 没有自己的 IDE 扩展本体, 所以"装扩展"这件事目前只能指向 Claude Code 的官方扩展(anthropic.claude-code), 而那个扩展的 RPC 协议 sid-code 并不完全兼容(§8.2)。
2.9 支持矩阵与「为什么有的 IDE 不自动检测」
📄 chapter-16 记载 Claude Code 支持两大家族:VS Code 系(VS Code / Cursor / Windsurf) 和 JetBrains 系(IDEA / PyCharm / WebStorm / GoLand / Android Studio 等)。
有一个细节很有意思:Aqua、Gateway、Fleet 刻意不自动检测, 因为它们的进程关键词太通用,容易误匹配到别的程序。
"宁可不做,也不要做错" —— 在检测类功能上,误报的代价通常高于漏报: 漏报用户会手动配置(一次麻烦),误报会连到错误的目标(长期困惑,且难归因)。 这和 §2.2 那个"多个匹配就放弃"是同一个判断。
2.10 本章小结:可选增强的四条实现要求
IDE 集成的设计哲学是"IDE 是可选增强,不是依赖"。这句话要落地成四条:
- 零侵入 —— 不改 IDE 的任何配置,只被动读 lockfile
- 渐进增强 —— 没 IDE 时全部功能正常;有 IDE 时多出选区/diff/提及
- 协议复用 —— IDE 就是一个 MCP Server,复用连接管理与通知机制
- 容错优先 —— 所有 IDE RPC 都包 try-catch,断开不影响主流程
🔬 sid-code 的代码里能看到 1/3/4 都做到了(unsupported 返回值、 closeAllDiffTabs 的静默 catch、scope: "dynamic")。 第 2 条是断的——不是因为设计错,是因为增强的部分没接线(§8.2)。
2.11 本章自检
- 为什么不用进程扫描做 IDE 发现?lockfile 多给了什么?
authToken防的是谁?(提示:不是"别的机器")- 为什么"多个 IDE 匹配"时要放弃而不是猜一个?
- diff 展示为什么不是一个普通的通知,而是阻塞调用?
- 选区和 @提及在语义上的关键差别是什么?
- 举一个"能力矩阵的空格不是因为偷懒"的例子。
第 3 章 · Bridge:让手机操控你本地的机器
本章解决 §0.1 场景二。这一章几乎不涉及 IDE,它是一个纯分布式系统问题。 如果你做过后端,这章会读得很快,但请留意 §3.7(权限代理)——那是 agent 特有的东西。
3.1 问题的真正形状
先把问题说准,因为很多人第一反应就想错了方向。
你在手机上打开 claude.ai,输入"帮我修 login 的报错"。这句话要变成 你家那台 Mac 上的文件读写和命令执行。
第一反应常常是"这是个权限/认证问题"。不是。 认证只是配菜,主菜是网络:
手机 ──── 想连 ────▶ 你家的 Mac
❌ 它没有公网 IP
❌ 它的内网 IP(192.168.1.7)外网不可路由
❌ 它的公网出口 IP 是路由器的,且随时会变
❌ 路由器不会把外来连接转给它(没做端口映射)这叫 NAT(网络地址转换):你家几十个设备共用运营商给的一个公网 IP, 路由器维护一张表记录"内网谁在和外网谁通信"。这张表只有在内网主动发起连接时才建条目。 外面来的、表里没对应条目的包,路由器不知道该转给谁,直接丢掉。
所以核心障碍一句话:你家的机器不是一个可以被连接的地址。
3.2 ★ 两条路:穿透 vs 中继
这是本章最重要的一个设计决策,也是面试高频题。
方案 A:NAT 穿透
一堆技巧的统称(STUN / TURN / ICE / UDP 打洞)。核心思路是利用 NAT 表的特性: 让两边同时向对方发包,各自的 NAT 表都建上条目,然后包就能通了。 WebRTC 就是这么干的(视频通话都靠它)。
代价:
- 依赖 NAT 类型。对称型 NAT(很多企业网、部分运营商)打不通,得回落到中继
- 需要一台信令服务器帮两边交换地址信息——所以你还是需要云端,只是它不转发数据
- 实现复杂度高得多。ICE 的候选地址收集、优先级排序、连通性检查,是一整套状态机
方案 B:云端中继
换个角度:既然外面连不进来,那就让里面连出去。
┌──────────┐ ┌──────────────────┐ ┌──────────────┐
│ 手机 / │ ── 连出去 ──▶ │ 云端中继服务器 │ ◀── 连出去 ── │ 你家 Mac │
│ claude.ai │ │ (有公网 IP) │ │ agent 进程 │
│ │ ◀──────────── │ 转发 + 认证 │ ──────────▶ │ │
└──────────┘ └──────────────────┘ └──────────────┘两边都是 Client,都主动连向那台有固定地址的服务器。NAT 完全不构成障碍 ——因为从来没有人试图"连进"内网。
这正是 §1.4 那条判据的应用:能被稳定找到的那一方当监听方, 所以云端监听,两端都连出去。
三方对照
| 维度 | 云端中继 | NAT 穿透(STUN/TURN) | P2P(WebRTC) |
|---|---|---|---|
| 可靠性 | 高(就是普通 HTTPS) | 中(看 NAT 类型) | 低(还要信令服务器) |
| 延迟 | 中(多一跳) | 低(直连) | 低(直连) |
| 隐私 | 消息过云端 | 不过第三方 | 不过第三方 |
| 实现复杂度 | 低 | 高 | 很高 |
| 企业网可用性 | 高(HTTPS 一定通) | 低 | 低 |
📄 为什么最终选中继:一个漂亮的论证
这里有一个论证值得完整学一遍,因为它示范了怎么正确地评估一个 trade-off:
看起来中继唯一的硬伤是"消息经过云端"(隐私成本)。 但对一个 coding agent 来说:
消息本身反正要发到云端做推理。 你的代码、你的对话、你的文件内容, 都要作为 prompt 发给模型 API。所以"消息经过云端"不是中继引入的新成本, 它是这个产品形态自带的。
一旦这一条成立,中继在其他三个维度全面胜出,选择就不再是权衡而是显然。
🎯 面试满分回答的结构(这是本章最值钱的一段):
- 先说清障碍是网络不是权限(NAT 不接受入向连接)
- 说清方向是反的:不是云端连过来,是本地连上去领活
- 列出中继唯一的代价是隐私
- 然后论证这个代价在这个特定产品里为零,因为推理请求本来就出网
第 4 点是区分度所在。很多人能答出 1-3,能自己论证到第 4 点的少。 更强一点还可以加一句反面:如果这是一个本地模型的 agent(推理不出网), 那这个论证就不成立了,隐私成本是真的,NAT 穿透会重新变得有吸引力。 说得出"这个结论在什么条件下会反转",才证明你不是在背理由。
3.3 两代架构:为什么会有两套并存
📄 Claude Code 的 Bridge 经历过一次大重构,两代同时存在。 理解这个演化能学到"渐进迁移"的真实成本。
v1:Environment-based(基于环境)
思路是"注册一个工作环境,然后长轮询等活干":
本地 agent 云端
│ │
① ── register ──────────────────────▶ Environments API
│ ◀── environment_id ──────────────── │
│ │
② ── poll(长轮询,等用户发起会话)──▶ 工作队列
│ ◀── WorkResponse ────────────────── │
│ │
③ ── acknowledge ─────────────────────▶ │
│ │
④ spawn 一个 agent 子进程 │
│ └── 子进程 ◀══ WS/SSE 双向 ══▶ Session Ingress
│ │
⑤ ── heartbeat(续租,防被回收)─────▶ │五步才建立起来。而且注意 ④:它派生一个子进程来跑会话, 父进程只负责轮询和管理。
v2:Environment-less(无环境)
本地 agent 云端
│ │
① ── POST /bridge(拿 OAuth token 换)─▶ Code Sessions API
│ ◀── worker_jwt ──────────────────── │
│ │
② ── POST /worker/register ──────────▶ │
│ │
③ ◀══ SSE 读 ═══════════════════════ /worker/events
│ ── HTTP POST 写 ─────────────────▶ /worker/events三步,单进程直连,没有工作队列也没有子进程。
对照与代价
| 维度 | v1 | v2 |
|---|---|---|
| 建立步骤 | 5 步 | 3 步 |
| 进程模型 | 父轮询 + 子进程执行 | 单进程 |
| 延迟 | 轮询间隔 + 子进程启动 | 即时 |
| 复杂度 | 环境生命周期 + 工作队列 + 子进程管理 | 低 |
| 多会话 | 原生支持(worktree / 同目录) | REPL 单会话 |
v2 明显更好,那为什么 v1 还留着?——因为最后一行。 remote-control 独立服务器模式(第 6 章)需要一个进程同时跑多个会话, v2 目前做不到。
📄 两代通过 feature flag 切换。这个"渐进迁移"的账要算清楚:
买到了:灰度发布、可秒级回滚、v1 的能力不丢。
付出了:
- 代码复杂度接近翻倍(两套传输、两套初始化、两套配置)
- 测试矩阵膨胀(每个功能要在两条路径上都验)
- 新功能要实现两遍
🎯 这是典型的"用短期复杂度换长期安全性"。面试里被问"你怎么做大改造的迁移", 能把这四条代价说清楚,比说"用 feature flag 灰度"强得多—— 后者是所有人都知道的答案,前者显示你真付过这个账。
3.4 启动前的九道门:为什么这么谨慎
📄 Claude Code 的 Bridge 启动不是"连服务器",是九道准入检查串起来, 任一道不过就返回 null:
① 编译期开关 feature('BRIDGE_MODE') → 外部构建里这段代码被整个删掉
② 运行时灰度开关 服务端可随时关闭
③ OAuth 认证 必须有 claude.ai 订阅的 token
④ 组织策略 企业管理员可禁用 Remote Control
⑤ token 有效性 主动刷新快过期的 token,避免 401 风暴
⑥ 跨进程退避 同一个死 token 被 3 个进程发现后,后续进程直接跳过
⑦ 版本下限 服务端可强制要求最低客户端版本
⑧ 组织 UUID v1 注册 / v2 归档都要
⑨ v1/v2 分支 走哪条路为什么一个"连服务器"要设九道门? 因为这个功能允许远程执行本地命令。 它的风险等级和别的功能不是一个量级。这九道门里有三类,值得分开看:
| 类型 | 哪几道 | 它防什么 |
|---|---|---|
| 能力门控 | ①②⑦ | 我们自己想不想让它开(灰度、回滚、强制升级) |
| 授权门控 | ③④ | 用户和用户的组织允不允许 |
| 健康门控 | ⑤⑥⑧ | 别在坏状态下反复自杀 |
第 ① 道特别值得注意:编译期开关意味着在某些构建产物里, Bridge 的代码根本不存在(死代码消除掉了)。这不是运行时判断, 是"这个二进制里没有这个功能"。对高风险功能,这是最强的门。
★ 第 ⑥ 道:跨进程退避(本章最精巧的设计)
这一节单独讲,因为它是一个很少见但很漂亮的分布式设计。
问题场景:用户的 OAuth refresh token 失效了(改了密码、离开了组织)。 但 access token 上写的 expiresAt 还没到。于是每个新启动的 agent 进程都会: 尝试刷新 → 失败 → 拿着过期 token 调 API → 401。
📄 chapter-16 记载监控上看到:单个 IP 每天 2879 次这样的 401。
为什么这很糟:这不是一次失败,是一台机器上每次启动都必然重演的失败。 用户完全无感(Bridge 只是没连上),但服务端在被稳定地打。
解法:用 expiresAt 当这个 token 的指纹,把失败记录写进全局配置:
第 1 个进程发现 token 死了 → 写 { deadExpiresAt: <指纹>, failCount: 1 }
第 2 个进程 → failCount: 2
第 3 个进程 → failCount: 3
第 4 个进程 看到 failCount ≥ 3 且指纹一致 → 直接跳过,不再尝试 ✅最漂亮的部分在这里:用户重新登录后,新 token 有不同的 expiresAt, 指纹对不上,退避记录自动失效。
不需要任何显式的"清除退避"逻辑。 一个自然会变的值当指纹,让状态自己过期。
这个模式值得记住,它可以推广:当你需要"针对某个坏状态退避, 但状态恢复后要自动解除"时,找一个「随状态变化而变化的值」当 key, 就不需要写解除逻辑了。 写解除逻辑就得考虑"什么时候解除", 而那个判断通常会做错(解除太早 = 退避无效;解除太晚 = 用户修好了还连不上)。
🎯 面试点:被问"你怎么做限流/退避",一般答案是 token bucket 或指数退避。 讲这个例子的区分度在于它是跨进程的、且自动失效的—— 而且你能说清"为什么不加显式清除逻辑"。
3.5 消息协议:三类消息与一道过滤
Bridge 上跑的消息分三类,方向不同:
云端 ──────────▶ 本地 agent(入向)
user 用户输入的 prompt
control_request 控制指令
├─ initialize 初始化会话
├─ set_model 切模型
├─ interrupt 中断当前操作
├─ set_permission_mode 切权限模式
└─ can_use_tool 权限相关
control_response 用户对权限请求的回复
本地 agent ──────▶ 云端(出向)
assistant Claude 的回复(含 tool_use)
tool_result 工具执行结果
result 会话完成
control_response 对 control_request 的应答
keep_alive 心跳帧🔬 sid-code 的版本简化很多(packages/core/src/bridge/types.ts:6-19): 出向 5 种(text / tool_use / tool_result / status / permission_request), 入向 3 种(user_message / permission_response / control)。 control 只认 abort 和 ping 两个命令(🔬 bridge-core.ts:139-150)。
★ 消息过滤:不是所有内部消息都该出门
这是一个容易被跳过但很重要的设计点。agent 内部的事件流里有很多东西不该转发:
📄 Claude Code 的过滤规则:
- 虚拟消息(REPL 内部生成的、只为显示用的)→ 不发
- 只有
user/assistant/ 斜杠命令产生的system消息 → 发 tool_result、progress等内部噪音 → 不发
🔬 sid-code 用一个白名单(bridge-messaging.ts:70-79):
const ELIGIBLE = new Set([
"text", "text_delta", "tool_use", "tool_result", "turn_complete", "error",
]);为什么必须过滤,三个理由按重要性排:
- 信息泄漏 —— 内部调试事件可能带着不该出网的东西(路径、环境变量、 中间推理)。一旦转发到云端,就是"发出去了",删不回来。
- 带宽与成本 —— 流式生成会产生每 token 一个事件。全转发就是把 token 数量级的消息推上网。
- 对端理解不了 —— 远端的 UI 只认几种消息类型,多出来的它得忽略, 而"忽略未知类型"这个逻辑写错就会崩。
⚠️ 🔬 这里有一个真实的不一致,而且它的形态特别值得学: sid-code 的
isEligibleForBridge()定义了白名单,但真实转发路径 (bridge-runner.ts:157-176的forwardEvent())是一个手写 switch, 从不调用这个白名单函数。全仓 grep 这个符号命中 6 处,逐一分类后是这样(附录 A 给了复跑命令):
命中位置 是什么 算调用吗 bridge-messaging.ts:67定义本身 ❌ tests/bridge/ws-transport.test.ts× 4单测直接调它,而且是绿的 ❌ 不在生产路径上 bridge-runner.ts:10注释里画的数据流图写着它在链路上 ❌ 注释不是代码 生产调用点:0 个。
三件事叠在一起,构成一个几乎不可能被发现的形态: 白名单有测试且测试全绿(所以"有覆盖")、 注释明确声称它在转发链路上(所以读代码的人会相信)、 而真实链路用的是另一套判断。两套判断今天恰好一致, 但它们会各自漂移,漂移时没有任何东西会红。
这正是本仓记忆里两条铁律的交汇点:
dead-wiring-has-three-boundary-forms(死接线的边界形态) 与gate-assertions-must-read-code-not-comments(判据必须读代码不能读注释)。 后者那次的教训是"我自己写的注释骗过了我自己写的门禁"—— 这里是同一件事的另一面:注释骗过了读代码的人。
3.6 消息去重:为什么必须做,以及为什么不能用普通 Set
为什么会收到重复消息
两个原因,都不可避免:
① 回显。 本地发一条消息给服务端,服务端广播给"所有连着的客户端"—— 包括发送者自己。不去重的话,你会收到自己刚发出去的那条。
② 重放。 断线重连后,服务端从上次的位置重发(这正是 §1.5 说的 Last-Event-ID 机制)。边界处的几条消息必然会重复—— 因为服务端不知道你到底处理完了哪一条。
注意②这个重复是协议的必然结果,不是 bug。 断线续传的语义是 at-least-once(至少一次),不是 exactly-once。 要 exactly-once 的效果,必须在接收端做幂等。 这是分布式系统的基本功。
为什么不能用普通 Set
因为 Bridge 是长生命周期的。一个 remote-control 进程可能跑好几天。 用普通 Set 存所有见过的 ID,内存单调增长,最后 OOM。
这类问题的特点是:测试跑 10 分钟完全正常,跑三天才崩。
解法:环形缓冲 + Set 双结构
🔬 packages/core/src/bridge/message-dedup.ts(43 行,容量默认 10000):
class BoundedUUIDSet {
private ring: (string|undefined)[] // 环形数组:记录插入顺序
private set = new Set<string>() // 哈希集合:O(1) 查询
private writeIdx = 0
add(uuid) {
if (this.set.has(uuid)) return // 已在集合里,不占新槽位
const evicted = this.ring[this.writeIdx]
if (evicted !== undefined) this.set.delete(evicted) // 驱逐最旧的
this.ring[this.writeIdx] = uuid
this.set.add(uuid)
this.writeIdx = (this.writeIdx + 1) % this.capacity
}
has(uuid) { return this.set.has(uuid) }
}两个数据结构各管一件事,这是这个设计的要点:
| 结构 | 负责 | 复杂度 |
|---|---|---|
Set | 回答"见过吗" | O(1) |
| 环形数组 | 记住"谁最老,该驱逐谁" | O(1) |
只用 Set 无法知道谁最老;只用数组查询是 O(n)。两个一起用, 内存 O(容量)、查询 O(1)、插入 O(1)。
容量怎么选? 它是一个明确的权衡:容量 = 你能容忍的重放窗口。 如果服务端最多重放 1000 条,容量 10000 绰绰有余。 容量太小的后果很隐蔽:一条早期消息被驱逐后又重放过来, 会被当成新消息重复执行一次。
🎯 面试点:这题常以"实现一个 LRU"的形式出现。 但这里的重点不是数据结构,是为什么需要有界(长生命周期进程) 和容量选小了的后果是什么(不是内存问题,是重复执行)。
3.7 ★ 权限代理:Bridge 里最 agent-specific 的一块
前面几节的问题在任何远程控制系统里都存在。这一节是 coding agent 独有的。
问题
agent 要执行 rm -rf build/。按规矩这要用户确认。 但用户不在本地终端前,他在手机上。
那个"你确定吗 [y/N]"要跨越整个中继链路问出去,再把答案传回来。
本地 agent 云端 手机
│ │ │
│─ 权限请求 ──────▶│──── 弹窗 ────▶│
│ │ │ 用户思考…
│ ⏸ 阻塞等待 │ │ 点"允许"
│◀─ 决定 ──────────│◀─── 回复 ─────│
│ │ │
▼ 执行工具三个必须做对的细节
🔬 sid-code 的实现(packages/core/src/bridge/permission-proxy.ts,94 行):
① 必须有超时,而且超时必须是拒绝
const timer = setTimeout(() => {
this.pendingRequests.delete(requestId);
getLogger().warn("BRIDGE", `权限请求 ${requestId} 超时,自动拒绝`);
resolve(false); // ← 超时 = 拒绝
}, this.timeoutMs); // 默认 60_000用户可能把手机锁屏了、可能地铁没信号了、可能压根没看到通知。 如果没有超时,agent 会永久卡住——而且卡在一个看不出原因的地方。
超时的方向必须是拒绝(fail-closed),这一条不能妥协。 如果超时默认放行(fail-open),那攻击面就是"让用户收不到通知"—— 断网 60 秒就能让任意危险操作自动获批。
⚠️ 这一条在本仓有真实教训:记忆里
proxy-port-root-fix-direct-plus-launchd记录过一个在途容器守卫 fail-open 的 bug——探测命令失败时输出被当成 "没有容器,可以放心重启"。最该拦的时候放行。 安全判断的默认方向,永远朝"拒绝"倒。
② 发送失败要立刻拒绝,不要等超时
.catch((err) => {
clearTimeout(timer);
resolve(false); // 立刻拒绝,不必等 60 秒
});连接已经断了,那 60 秒的等待是纯浪费——答案不可能回来。 已知不可能成功时立刻失败,这让用户看到的是"连接断了"而不是"卡了一分钟然后拒绝了"。
③ 连接关闭要清理所有 pending 请求
🔬 bridge-core.ts:73-76:连接 close 时调 permissionProxy.cleanup()。 否则那些 Promise 永远不 resolve,调用方永远等着——这是Promise 泄漏, 比内存泄漏更难查,因为它表现为"某个操作莫名卡住"。
权限决定可以携带更多信息
📄 Claude Code 的权限响应不只是 allow/deny,还能带:
updatedInput—— 用户改了工具的输入("跑这个命令,但把-rf去掉")updatedPermissions—— 用户更新了权限规则("以后这类操作都别问我了")
这让远端用户拥有和本地终端用户同等的权限控制能力。 这是一个重要的对等性: 如果远程只能 allow/deny,那远程操控就是一个降级体验,用户会不敢用。
🔬 sid-code 的权限代理目前是布尔的(requestPermission 返回 Promise<boolean>),没有 updatedInput / updatedPermissions。 这是一个明确的能力差距,见 §8.3。
3.8 崩溃恢复:Bridge Pointer
📄 Bridge 进程会崩(OOM、系统重启、误杀)。崩了之后用户希望能接着来, 而不是从头开始。
做法是一个指针文件:
// ~/.claude/bridge-pointer.json
{
"sessionId": "session_abc123",
"environmentId": "env_xyz789",
"source": "repl",
"updatedAt": 1711900000000
}会话建立后立刻写,之后定期刷新 mtime 当存活信号。 --continue 时读这个指针,用 --session-id 恢复。
TTL 是 4 小时,而且刻意和服务端的回收时间对齐。 这一点值得单独讲:
如果本地 TTL 比服务端长,就会出现"本地觉得能恢复,服务端已经把会话清了" ——用户看到一个失败的恢复,比看到"没有可恢复的会话"更困惑。
两侧的过期时间必须对齐,或者本地更短。 这是所有带 TTL 的双端状态的通则。
📄 还有一个有意思的细节:readBridgePointerAcrossWorktrees() 会在多个 git worktree 里找最新的指针。因为 worktree 模式下(第 6 章)每个会话可能 在不同目录,指针位置也不同。
🔬 sid-code 没有 Bridge Pointer 机制。它的 Bridge 是 CLI 参数驱动的 常驻进程(--bridge <url>),崩了就是崩了,重启要重新给 URL。见 §8.3。
3.9 Flush Gate:历史消息的有序投递
📄 一个小而精的状态机。场景:Bridge 刚连上,要把本地已有的对话历史同步上去 (这样手机上能看到之前聊了什么)。但用户此刻可能正在输入新消息。
如果历史消息和新消息交错发出去,服务端收到的是乱序的流。
┌──────────┐ 历史投递完成 ┌──────────┐
│ GATING │ ───────────────▶ │ OPEN │
│ 新消息入队 │ │ 新消息直发 │
└──────────┘ └──────────┘为什么不干脆"等历史发完再让用户输入"? 因为那会阻塞用户。 FlushGate 让用户立刻能打字,同时保证顺序——代价只是一个队列。
这是一个通用模式:当"初始化"和"正常工作"会竞争同一个输出通道时, 用一个门把正常工作的输出先攒起来,初始化完成后再放行。 比"阻塞用户直到初始化完成"体验好,比"不管顺序"正确。
3.10 本章自检
- 手机远程操控本地机器,核心障碍是什么?(不是权限)
- "远程控制"这个说法从网络层面看为什么是反的?
- 中继唯一的代价是隐私 —— 为什么这个代价在 coding agent 里接近于零? 在什么条件下这个论证会反转?
- 为什么消息去重不能用普通 Set?容量选小了的后果是什么(不是 OOM)?
- 权限请求超时时,为什么必须是拒绝而不是放行?
- 跨进程退避为什么不需要写"清除退避"的逻辑?
- v1/v2 并存买到了什么、付出了什么?(要能说出四条代价)
第 4 章 · 传输层:网络是不可靠的
第 3 章讲的是"消息该长什么样、该往哪走"。这一章讲消息在路上会遇到什么。 这一章的内容在任何长连接系统里都通用,不限于 agent。
4.1 先列清单:到底会出什么事
写传输层之前,先把要处理的边界情况列全。这份清单本身就是面试的好答案:
| 会发生什么 | 频率 | 表现 |
|---|---|---|
| WiFi 切换 / 进电梯 | 每天多次 | 连接断,几秒后网络回来 |
| 笔记本合盖休眠 | 每天多次 | 连接断,而且醒来时定时器的时间感是错的 |
| 代理回收闲置连接 | 60 秒无数据就可能发生 | 连接被静默掐断 |
| 服务端重启 / 发版 | 每天 | 所有连接一起断(惊群) |
| 消息丢失 | 偶发 | 对端少收一条,没有任何报错 |
| 消息乱序 | 用 HTTP POST 时必然 | 后发的先到 |
| 背压 | 本地生成快于网络发送 | 队列增长 → OOM |
注意第 2 行和第 5 行,它们是本章两个最难的点:
- 休眠让"时间"变得不可信 —— 记忆里有一条真实教训:
host-sleep-pollutes-agent-ms-and-revives-timeout(宿主休眠 717 秒污染耗时统计, 让超时闸门看起来像坏了)。长连接的所有定时逻辑都要考虑"这台机器可能刚睡了两小时"。 - 消息丢失没有报错 —— 这是最恶劣的一类:
write()返回成功,对端就是没收到。 没有任何一层会告诉你。所以需要主动检测(§4.6)。
4.2 三代传输:一条真实的演化路径
📄 Claude Code 有三套传输实现并存。它们不是"三个选项",是三代—— 每一代解决上一代的一个具体问题。这个演化过程比结论更有价值。
第一代:纯 WebSocket(双向)
agent ◀══════ WS 双向 ══════▶ 云端最直觉的方案。遇到的问题:企业代理经常不支持 WS 升级握手(§1.5), 在某些客户的网络环境里根本连不上。
第二代:Hybrid(WS 读 + HTTP POST 写)
agent ◀══════ WS 只读 ═══════ 云端
agent ──── HTTP POST 写 ────▶ 云端为什么把写拆出来? 两个理由:
- HTTP POST 穿透代理比 WS 可靠得多——它就是一个普通请求
- POST 天然支持批量:一次请求塞 500 条消息。WS 上要发 500 帧
第三代:SSE 读 + HTTP POST 写(v2)
agent ◀══════ SSE 只读 ══════ 云端
agent ──── HTTP POST 写 ────▶ 云端为什么读也换掉? 因为 WS 的重连要自己实现消息游标(§1.5), 而 SSE 的 Last-Event-ID 是协议自带的。把自己写的逻辑换成协议提供的能力, 就少了一整类 bug。
三代对照
| 一代 WS | 二代 Hybrid | 三代 SSE+POST | |
|---|---|---|---|
| 读 | WS | WS | SSE |
| 写 | WS | HTTP POST | HTTP POST |
| 代理兼容 | 差 | 中(读还是 WS) | 好(全是 HTTP) |
| 断线续传 | 自己实现 | 自己实现 | 协议内置 |
| 批量写 | 麻烦 | ✅ | ✅ |
🎯 这个演化路径是很好的面试素材,因为它示范了"每一步都只解决一个问题"。 讲的时候按"遇到什么 → 改什么 → 又遇到什么"的顺序讲, 比直接说"最终方案是 SSE+POST"有说服力得多。
加分点:指出三代并存的代价——三套代码、三套测试、 新功能要在多条路径上实现。这和 §3.3 的 v1/v2 是同一笔账。
🔬 sid-code 目前只有 WebSocket 一种(transport.ts:12-17, URL 不是 ws:///wss:// 就直接抛错)。它是一代形态,但保留了传输抽象接口 (BridgeTransport),加第二种不用改上层。见 §8.3。
4.3 重连:四个必须做对的细节
🔬 sid-code 的重连实现(packages/core/src/bridge/ws-transport.ts:183-208) 虽然只有 26 行,但四个关键点都在,逐个讲。
① 指数退避
const delay = Math.min(
RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt - 1), // 1s, 2s, 4s, 8s...
RECONNECT_MAX_DELAY_MS // 上限 30s
) * (0.8 + Math.random() * 0.4); // 抖动 ±20%为什么要退避:服务端可能是因为过载才断你的。你立刻重连、失败、立刻再连, 就是在给一个已经倒下的服务加压。
为什么要上限:不设上限的话,退避一小时后要等 2^12 秒 = 一个多小时才重试一次。 用户的网络早恢复了,程序还在睡。上限 30 秒意味着最坏情况下 30 秒内必然重试。
② 抖动(jitter)—— 这一条最容易被漏
那个 * (0.8 + Math.random() * 0.4) 是干什么的?
场景:服务端发版重启,1 万个客户端同时断线。 如果退避曲线完全一致,这 1 万个客户端会在第 1 秒、第 3 秒、第 7 秒… 整齐地同时重连。服务端刚起来就被 1 万个并发请求打死,再挂,再重启, 再被打死——这叫惊群(thundering herd),是一个能自我维持的故障。
抖动让每个客户端的等待时间随机偏移 ±20%,把尖峰摊平成一段。
🎯 面试点:能主动说出"退避必须带抖动,否则服务端重启时会遭遇惊群", 这是一个明确的经验信号。很多人只答"用指数退避"。
③ 总预算(10 分钟就放弃)
🔬 RECONNECT_GIVE_UP_MS = 10 * 60 * 1000。循环条件是 Date.now() - reconnectStartTime < RECONNECT_GIVE_UP_MS。
为什么不永远重试? 因为"永远重试"会掩盖真实故障。 如果用户的 token 过期了、URL 写错了、服务下线了, 永远重试的表现是"程序一直在跑但什么都不做"——用户看不出区别。
10 分钟后放弃并明确报错(🔬 getLogger().error("BRIDGE", "重连超时(10 分钟),放弃")), 用户就知道该去查配置了。
注意这里是「总预算」而不是「重试次数」,这个选择是对的: 次数上限在退避后期意味着很长的墙钟时间(10 次退避可能就是半小时), 而墙钟时间才是用户感知的量。
④ 区分「用户主动关」和「意外断开」
🔬 代码里有一个 closedByUser 标志,在 close() 里置 true, 重连循环每次都检查它。
没有这个标志会怎样:用户按 Ctrl+C 退出 → 连接关闭 → 重连逻辑 认为"断线了"→ 开始重连一个用户已经不想要的连接。进程退不掉。
记忆里
tui-exit-hang-no-failsafe就是这类问题:退出时有东西还在跑。 所有自动重试逻辑都必须能区分"失败"和"不干了"。
📄 一代 WS 还处理了两件 sid-code 没处理的事
| 机制 | 干什么 |
|---|---|
| 系统休眠检测 | 检测到合盖/唤醒后立即重连,不等退避 |
| 永久失败码 | 1002(协议错)/ 4001(认证失败)/ 4003(会话过期)不重试 |
第二条特别重要,它是 §4.3③ 的精细化:
不是所有失败都值得重试。认证失败重试 10 分钟, 只是把一个"立刻能报出来的错"拖成"10 分钟后才报的错"。
这条和记忆里 attribution-decoupled-from-signal-antipattern 是同一个道理: 判据优先级是状态码 > 数字边界 > 裸子串。HTTP/WS 的状态码是权威判据, 拿到 401 就该立刻放弃,不该走通用重试路径。
🔬 sid-code 的重连不区分关闭码(只看 event.code !== 1000, 见 ws-transport.ts:84),所以认证失败也会重试满 10 分钟。见 §8.3。
4.4 心跳:两个不同的目的,别混为一谈
🔬 sid-code 的心跳(ws-transport.ts:152-158,间隔 30 秒)发一个 {type:"status", data:{ping:true}}。
心跳其实在同时干两件不同的事,理解这个区分很重要:
| 目的 | 谁需要它 | 判据 |
|---|---|---|
| ① 保活:告诉中间设备"这条连接在用,别掐" | 代理 / 负载均衡 / NAT 表 | 间隔必须小于最短的空闲超时 |
| ② 探活:检测"对端还在吗" | 我们自己 | 要有超时判定:发了 ping,多久没 pong 就认为断了 |
这两件事的间隔要求不同:
保活的间隔由中间设备决定。记忆里有一条真实事故: claude-code-502-is-proxy-60s-idle-timeout —— 代理的空闲超时是 60 秒。 心跳间隔必须明确小于它。30 秒是安全的(留了一倍余量)。
探活需要一个回执机制。这里有一个 🔬 值得指出的现状:
sid-code 只发 ping,没有 pong 超时判定(grep
pong只在bridge-core.ts:147作为响应远端 ping 出现,没有"我发的 ping 没等到回应"这条路径)。所以它的心跳目前只实现了目的 ①(保活),没实现目的 ②(探活)。 后果是:如果连接进入"半开"状态(TCP 层没断、但对端进程已死), 我们会一直往一个黑洞里写,而
isConnected()返回 true。 这类"连接看起来是好的但实际上是死的"状态叫半开连接(half-open), 只能靠"发出去的探测没有回应"来发现。
🎯 面试点:被问"心跳怎么做",多数人答"定时发个包"。 能区分保活与探活、并指出"只发不收等于没做探活",是明显的加分。
4.5 有序批量上传:一个微妙的并发问题
🔬 packages/core/src/bridge/serial-batch-uploader.ts(103 行)。 这个组件解决的问题比它看起来重要。
问题:HTTP POST 不保证顺序
同时发两个 POST:
POST A(消息 1-100) ──慢──────────▶ 服务端收到(第二)
POST B(消息 101-200)──快─▶ 服务端收到(第一)
服务端看到的顺序:101-200,然后 1-100 ❌对话消息乱序的后果是远端 UI 显示错乱:回复出现在提问前面。
解法:串行化 —— 同时最多 1 个在途请求
🔬 关键字段是 private inflight: T[] | null。 drain() 的第一行就是 if (this.draining || this.inflight !== null || this.stopped) return; —— 有在途请求就直接返回,不发第二个。
enqueue(e1) ─┐
enqueue(e2) ─┤ 攒进 pending 队列
enqueue(e3) ─┘
│
▼ splice 出一批(≤ maxBatchSize = 500)
┌────────────┐
│ POST 请求 │ ← 同时最多 1 个
└─────┬──────┘
│ 成功 → 发下一批
└ 失败 → 指数退避重试(同一批)串行 = 牺牲吞吐换顺序。 这是一个明确的取舍,而且是对的取舍: 对话消息的量级(每秒几十条)远没到需要并发上传的程度, 而顺序错乱是用户直接可见的 bug。
背压:队列满了怎么办
🔬 serial-batch-uploader.ts:40:
while (this.pending.length >= this.maxQueueSize && !this.stopped) {
// 等待队列腾出空间
}maxQueueSize 默认 10000。队列满时 enqueue() 会等——这叫背压。
为什么必须有背压:没有它的话,网络卡住时队列无限增长 → OOM。 而 OOM 的表现是进程被系统杀掉,比"发消息变慢"糟糕得多。
📄 Claude Code 的版本还有 maxConsecutiveFailures —— 连续失败超过上限就 丢弃当前批次,继续处理后面的。这是"有损但不崩溃"的降级:
在极端故障下,丢几条消息比整个进程卡死或 OOM 更可接受。 但这个决定必须是显式的、有日志的,否则它就变成了 §4.1 那个 "消息丢失没有报错"的问题。
🔬 sid-code 有 consecutiveFailures 计数用于退避(:85-92), 但没有丢弃上限——它会无限重试同一批。极端情况下会永久卡在一批上。
4.6 检测静默丢包
📄 chapter-16 提到 ReplBridgeTransport 接口里有一个 readonly droppedBatchCount: number。
这个字段单独值得一讲,因为它体现了一个重要的可观测性原则:
降级动作必须留下计数。
"丢弃了一批消息"这个动作如果没有计数器, 它就是一个完全不可观测的行为——线上出问题时你没法回答 "有没有丢消息"这个问题,只能猜。
这和本仓记忆里那条 defense-trigger-rate-measurement 是同源的: 防线/降级路径必须有触发计数,否则你分不清"没触发"和"没接线"。
🔬 sid-code 的 BridgeTransport 接口(types.ts:23-44)没有这个字段。
4.7 流事件合并:一个省带宽的巧思
📄 这一节讲一个很漂亮的优化。
问题:模型流式输出时,每个 token 一个 text_delta 事件。 一段 500 token 的回复 = 500 个事件。全推上网,开销巨大。
朴素解法:攒够 N 个再发。但这样远端的打字机效果就一顿一顿的。
Claude Code 的解法:把连续的 text_delta 合并成 "到目前为止的完整文本"快照(full-so-far),而不是增量。
增量模式: 发 "你" → 发 "好" → 发 "," → 发 "世界"
快照模式: 发 "你" → 发 "你好" → 发 "你好," → 发 "你好,世界"快照模式每条消息更大,但它有一个增量模式没有的性质:
快照是幂等的、且自带纠错能力。 中间丢了任意几条,只要最后一条到了,接收端显示的内容就是正确的。 增量模式丢一条,后面全部错位,而且永远不会自愈。
所以合并之后既减少了事件数(可以攒一批只发最后一个快照), 又消除了"丢一条就永久错位"这个失败模式。
🎯 这是一个可以推广的模式:当你要在不可靠通道上同步状态时, 传"当前完整状态"比传"变更增量"更健壮——代价是消息更大。 这也是为什么很多状态同步协议(比如某些 CRDT 的实现、 或者简单的配置下发)宁可传全量。
反过来,什么时候该传增量?当状态很大(传全量不现实) 且通道足够可靠(或者有独立的对账机制)时。
4.8 本章小结:可靠传输的九件事
把本章折成一张清单。设计任何长连接传输时,这九件事都要有明确的答案:
| # | 要做的事 | 做错的后果 | 🔬 sid-code |
|---|---|---|---|
| 1 | 指数退避 | 服务端过载时被你加压 | ✅ |
| 2 | 退避带抖动 | 服务端重启时惊群 | ✅ |
| 3 | 退避有上限 | 网络恢复了程序还在睡 | ✅ 30s |
| 4 | 总预算后放弃 | 真实故障被掩盖成"一直在跑" | ✅ 10min |
| 5 | 区分主动关闭 | Ctrl+C 退不掉 | ✅ closedByUser |
| 6 | 区分永久失败码 | 认证失败也重试 10 分钟 | ❌ |
| 7 | 心跳(保活 + 探活) | 半开连接写进黑洞 | ⚠️ 只有保活 |
| 8 | 写入串行化 | 消息乱序,UI 错乱 | ✅ inflight |
| 9 | 背压 + 降级计数 | OOM,或静默丢包无法排查 | ⚠️ 有背压无计数 |
这张表也是面试时"你会怎么设计一个可靠的长连接层"的答案骨架。 按这九条讲,每条说清"不做会怎样"。
4.9 本章自检
- 三代传输各自解决了上一代的什么问题?
- 退避为什么必须带抖动?不带会发生什么自我维持的故障?
- "重试 10 次"和"重试 10 分钟"哪个是更好的预算口径?为什么?
- 心跳的两个目的是什么?只发 ping 不收 pong 漏掉了哪一个?
- 为什么 HTTP POST 写入必须串行化?
- 为什么"传完整快照"在不可靠通道上比"传增量"健壮?代价是什么?
- 为什么"丢弃一批消息"这个动作必须有计数器?
第 5 章 · LSP:给 CLI 装上代码智能
本章解决 §0.1 场景三(它改错了但不知道)。 这一层和前两层有一个根本区别:语言服务器是 agent 自己派生的子进程, 它管对方的生死(回顾 §0.3 那张表的第三行)。
5.1 先讲清 LSP 是什么
IDE 之所以强大,不是因为它能高亮语法,是因为它背后有一个真懂这门语言的程序 在持续分析你的代码。这个程序知道:
foo这个变量的类型是什么- 这个函数被哪 7 个地方调用了
- 你刚才那个改动引入了一个类型错误
这个程序不在 IDE 里。 它是一个独立进程,叫语言服务器。 TypeScript 的是 tsserver,Python 的是 pyright,Rust 的是 rust-analyzer。
微软在 2016 年做了一件很聪明的事:把"编辑器"和"语言分析器"之间的通信 标准化成一个协议,叫 LSP(Language Server Protocol)。
为什么这件事聪明:在 LSP 之前,M 个编辑器 × N 种语言 = M×N 套集成要写。 有了 LSP,每个编辑器实现一次 LSP Client,每种语言实现一次 LSP Server, 变成 M+N。
LSP 之前(M×N) LSP 之后(M+N)
VSCode ─┬─ TS 插件 VSCode ─┐
├─ Py 插件 Vim ─┼─ LSP ─┬─ tsserver
└─ Rust 插件 Emacs ─┘ ├─ pyright
Vim ─┬─ TS 插件(另写) └─ rust-analyzer
└─ ...对我们的意义:agent 只要实现一次 LSP Client, 就白拿了整个生态所有语言的分析能力。这是本章能成立的全部前提。
5.2 三个必须解决的不匹配
把 LSP 接进一个 CLI agent,有三处根本不匹配:
| LSP 的假设 | agent 的现实 | 冲突在哪 |
|---|---|---|
| 我是有状态的:你要告诉我哪些文件打开了、内容是什么 | 工具调用是无状态的:读一个文件就返回 | 谁来维护"文件打开状态"? |
| 启动要索引整个项目,几秒到几十秒 | 用户希望 agent 秒起 | 不能在启动时同步等它 |
| 我主动推送诊断(异步、随时) | agent 是"请求→响应"的循环 | 推来的诊断往哪放? |
三个冲突对应三个设计,正好是本章的三节:状态同步(§5.4)、 懒初始化(§5.5)、异步投递(§5.6)。
5.3 三层架构:Client → Instance → Manager
🔬 sid-code 的 LSP 层是 10 个文件 1943 行(比 IDE 层的 943 行大一倍多, 它是这三层里最重的一块)。分三层:
┌────────────────────────────────────────────────────────────┐
│ LSPServerManager(server-manager.ts, 168 行) │
│ 职责:按文件扩展名路由 · 管理多个实例 · 文件生命周期同步 │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ LSPServerInstance │ │ LSPServerInstance │ │
│ │ (server-instance.ts) │ │ │ │
│ │ 职责:状态机 · 崩溃重启 │ │ │ │
│ │ ┌────────────────┐ │ │ ┌────────────────┐ │ │
│ │ │ LSPClient │ │ │ │ LSPClient │ │ │
│ │ │ (client.ts) │ │ │ │ │ │ │
│ │ │ 职责:JSON-RPC │ │ │ │ │ │ │
│ │ │ + 分帧 │ │ │ │ │ │ │
│ │ └───────┬────────┘ │ │ └───────┬────────┘ │ │
│ └──────────┼───────────┘ └──────────┼───────────┘ │
└─────────────┼─────────────────────────┼────────────────────┘
│ stdio │ stdio
▼ ▼
┌───────────┐ ┌───────────┐
│ tsserver │ │ pyright │
│ (子进程) │ │ (子进程) │
└───────────┘ └───────────┘为什么要分三层而不是一个类搞定? 因为三层各自变化的原因不同 (这是单一职责的真实判据,不是教条):
| 层 | 因为什么原因才需要改 |
|---|---|
| Client | LSP 协议本身变了、分帧有 bug |
| Instance | 崩溃恢复策略变了、超时策略变了 |
| Manager | 支持新语言、路由规则变了 |
加一门新语言只动 Manager 的配置;修分帧 bug 只动 Client。这就是分层买到的东西。
最底层的活:Content-Length 分帧
§1.2 讲过 stdio 是字节流不是消息流,要自己分帧。 🔬 packages/core/src/lsp/client.ts:174-208 就是这段活:
// 写:加长度头
const payload = `Content-Length: ${contentLength}\r\n\r\n${json}`;
// 读:先找 \r\n\r\n,解析出长度,再等够那么多字节
this.buffer += data; // 攒到缓冲区
const headerEnd = this.buffer.indexOf("\r\n\r\n");
const match = header.match(/Content-Length:\s*(\d+)/i);
const bodyBytes = Buffer.from(this.buffer, "utf-8");
// 够了才切出来处理,不够就继续等
this.buffer = bodyBytes.slice(this.contentLength).toString("utf-8");注意 Buffer.from(...) 那一步,它是一个容易写错的地方: Content-Length 数的是字节数,不是字符数。中文一个字 3 字节。 如果直接用 string.slice(contentLength),遇到中文注释就会切错位置, 然后后面所有消息全部错位,且不会有任何报错——因为你切出来的还是"看起来像 JSON 的东西", 只是 parse 失败或者内容错。
🎯 面试点:这是"字节 vs 字符"这个经典陷阱在真实协议里的样子。 能主动提这一点,说明你真处理过非 ASCII 的分帧。
5.4 状态同步:谁来维护"文件打开了"
LSP 服务器需要知道文件的当前内容,包括还没保存的修改。 所以协议里有四个通知:
| 通知 | 什么时候发 |
|---|---|
textDocument/didOpen | 开始关注这个文件 |
textDocument/didChange | 内容变了(带新内容或增量) |
textDocument/didSave | 保存了 |
textDocument/didClose | 不再关注 |
在 IDE 里,这些由编辑器发。在 agent 里,必须由 agent 在改文件时发。
🔬 sid-code 的做法是在工具执行路径上挂钩: packages/core/src/query/tool-executor.ts:1644 动态 import 了 syncFileToLSP。
这里有一个必须理解的点:如果 agent 改了文件但没通知 LSP, LSP 手上还是旧内容,它给出的诊断是针对旧代码的。
⚠️ 这个失效形态特别恶劣:诊断照常返回,只是全部过时。 没有报错,没有空结果,就是错的答案。agent 会拿着过时的诊断做决策—— 比如去修一个已经被它自己修掉的错误。
这和 §3.5 那个白名单一样,属于"绿着坏掉"。第 9 章会把这类形态归纳一遍。
5.5 懒初始化:不能在启动时等它
LSP 启动要索引项目,可能几十秒。不能同步等。
🔬 sid-code 的状态机(packages/core/src/lsp/manager.ts:16):
type InitState = "not-started" | "pending" | "success" | "failed";四个状态,配一个 waitForLSPReady(timeoutMs = 10000) (🔬 manager.ts:246-253),它的逻辑值得逐条看:
success → 立刻返回 true
failed → 立刻返回 false ← 不等!
not-started → 立刻返回 false ← 不等!
pending → 等,最多 10 秒只有 pending 才等,这是关键。 三个理由:
failed还等 = 白等 10 秒然后得到必然的失败not-started还等 = 等一件根本没启动的事,永远等不到pending才是"再等等就有了"的唯一状态
一般化:任何
waitForX()都必须区分"还没好"和"永远不会好"。 只判断"是不是好了"的实现,会在失败态上白等满超时。 这是一个很常见的 bug,而且它只在失败路径上出现,测试通常不覆盖。
📄 Claude Code 还多两个机制,🔬 sid-code 一个有一个没有:
① 代数计数器(generation counter) —— 🔬 sid-code 有,而且写对了 (manager.ts:21 的 initGeneration)。
它解决的问题很典型:初始化是异步的(loadLSPConfigs(...).then(...)), 如果中途有人重新初始化,旧的那条异步链还在飞, 它的 .then() 迟些会落地,把 initState 和 instance 写成旧配置的结果。
做法是进函数时取一个号,异步回调落地前对一次号:
const currentGen = ++initGeneration; // manager.ts:40
void loadLSPConfigs(workspaceFolder)
.then(async (configs) => {
if (currentGen !== initGeneration) return; // :44 过期的初始化,直接丢弃
...
})
.catch((err) => {
if (currentGen !== initGeneration) return; // :56 失败也要对号
});这个模式值得单独记住:任何"可被重新触发的异步初始化"都需要它。 判据是——如果在
await期间有人再调一次这个函数,会发生什么? 没有代数号的话,两条链会互相覆盖,而且后写的不一定是新的那条 (谁的 I/O 先返回谁先写)。这是一类很难复现的竞态。⚠️ 注意
.catch()里也要对号(:56)。只在成功路径对号是个常见漏法: 旧链的失败会把新链刚设好的pending覆盖成failed。
② bare / 无头模式跳过 —— 🔬 sid-code 没有(cli.ts:2144-2145 无门控)。 脚本模式、无头模式、Bridge 模式下也会起 LSP,白付索引开销。
这一条是一个好的判断:能力要不要启用,看这次运行的形态,不只看配置。
5.6 诊断投递:三个必须解决的量的问题
LSP 服务器会主动推诊断(textDocument/publishDiagnostics)。 如果原样转给模型,会出三个问题:
| 问题 | 具体形态 |
|---|---|
| 量太大 | 一个中型项目 tsc 报几百个错很常见,全塞进上下文就是几万 token |
| 重复 | 同一个错误会被反复推送(每次文件变动都重推一遍全量) |
| 时机不对 | 诊断是异步来的,可能正好在工具执行中途到达 |
解法一:限流(量的问题)
🔬 packages/core/src/lsp/diagnostic-registry.ts:17-19:
const MAX_DIAGNOSTICS_PER_FILE = 10;
const MAX_TOTAL_DIAGNOSTICS = 30;
const MAX_DELIVERED_FILES = 500; // LRU 上限而且是按严重度排序后再截断(🔬 :310 用 SEVERITY_ORDER 排序, Error > Warning > Info > Hint),所以截掉的一定是最不重要的。
为什么是 30 条? 📄 chapter-16 给了一个漂亮的算账: 每条诊断(路径 + 行号 + 描述)约 50-100 token,30 条 = 1500-3000 token。 再多边际收益递减——模型通常知道最严重的几个错就够修了, 而上下文预算是硬约束。
🎯 这个算账方式本身就是面试素材:把一个"限流阈值"的选择 从"感觉差不多"变成"token 预算 ÷ 单条成本 = 条数上限"。 关键是先算出单位成本,再算预算能买多少。
解法二:跨轮去重(重复的问题)
🔬 用一个 LRU(上限 500 个文件)记住"已经投递过哪些诊断"。 同一条不重复投。
为什么必须去重:不去重的话,每轮对话都会重新收到同样的 30 条诊断。 后果不只是浪费 token——模型会以为"我修了但还在报错", 然后反复尝试修同一个地方。这就是记忆里那个 maxUnchangedObservationRun (重复调用且返回值不变 ≥3 判病态)要抓的空转形态。
解法三:异步附件(时机的问题)
诊断不直接插进当前消息流,而是走**附件(attachment)**机制, 在下一轮对话时自然呈现。
为什么不直接注入:诊断是在 agent 编辑文件后由 LSP 异步产生的, 它不在当前的请求-响应循环里。硬插进去会打断正在进行的工具执行流。
🔬 sid-code 的形态是 collectDiagnosticText(), 在 query/loop.ts:187 和 agent/agentic-loop.ts:403 被调用—— 即主循环在下一轮组装上下文时主动来取,而不是诊断自己往里塞。
这是一个 pull vs push 的选择:让消费方在它方便的时候来取, 而不是让生产方在它产生的时候塞进去。 前者天然避免了"在错误的时机被打断"这一整类问题。
一个并发细节:子代理的诊断隔离
🔬 manager.ts:182-183 的注释提到一个很实际的问题:
「且只清空这些文件的 pending——供并发子代理隔离消费, 避免与主循环 / 其它子代理互相偷诊断」
"互相偷诊断" 这个说法很准确:诊断池是全局的, 如果子代理 A 取走时清空了全部 pending, 子代理 B 和主循环就永远看不到本属于它们的那些诊断了。 所以取的时候要按文件范围限定。
这类"共享池 + 多消费者 + 取走即清空"的 bug 很典型: 单消费者时完全正常,加了并发才暴露。
5.7 错误隔离:一个服务器崩了不能拖垮全部
🔬 packages/core/src/lsp/passive-feedback.ts:32-63 的结构值得注意—— 它有两层 try-catch:
for (const [serverName, instance] of servers) {
try { // ← 外层:注册处理器本身可能失败
instance.onNotification("textDocument/publishDiagnostics", (params) => {
try { // ← 内层:处理某条通知可能失败
// ...解析诊断...
} catch (err) {
log.error("LSP", `[${serverName}] 处理诊断通知失败: ${err.message}`);
}
});
successCount++;
} catch (err) {
errors.push(`${serverName}: ${err.message}`);
}
}
return { successCount, errors };两层的必要性(这是一个常被写漏的区分):
| 层 | 防什么 | 漏了会怎样 |
|---|---|---|
| 外层 | 注册阶段失败(一次性) | 一个服务器注册失败,循环中断,后面的服务器都没注册上 |
| 内层 | 运行阶段失败(每条通知) | 一条畸形诊断把整个通知处理器打挂 |
而且返回值是 { successCount, errors } 而不是抛异常—— 它把"部分成功"建模成了一个正常的返回值。这是对的: 3 个服务器里 2 个注册成功,那就是一个可用的状态,不该当失败处理。
崩溃重启:有上限的自动恢复
🔬 server-instance.ts 的状态机 + 崩溃计数:
stopped → starting → running → stopping
↓ ↓
error (崩溃)→ crashRecoveryCount++ → 重启
超过 maxRestarts(默认 3)→ error,不再重启为什么有上限(:191-193):如果一个语言服务器因为配置错误 每次起来就崩,无限重启就是一个 fork 炸弹。3 次之后放弃、置 error、 记日志,是对的。
注意 restartsExhausted 这个 getter(:47-48): 它把"重启次数用尽"暴露成一个可查询的状态,而不是只记在日志里。 这样上层能据此给用户提示(🔬 manager.ts:120 的 getLSPHealthWarning)。
降级了要能被观测到 —— 又一次是同一条原则(回顾 §4.6)。
5.8 ★ 为什么只做诊断:一个 80/20 的判断
LSP 能提供的能力很多:跳转定义、查找引用、自动补全、重命名、代码操作… 📄 Claude Code 的内置 LSP 主要只做诊断(被动反馈)。
为什么? 因为要问"每个能力对 agent 的边际价值是多少":
| LSP 能力 | agent 有替代品吗 | 边际价值 |
|---|---|---|
| 诊断(错误/警告) | 没有。跑 tsc 要几十秒且要知道命令;grep 找不出类型错误 | 高 |
| 跳转定义 | grep 一个符号名,八成能找到 | 低 |
| 查找引用 | grep 同名,会有假阳但可接受 | 中低 |
| 自动补全 | 模型本来就在"补全" | 几乎为零 |
| 重命名 | grep + 批量替换,风险可控 | 中低 |
诊断的独特性在于:它是 agent 用别的手段拿不到的信息。 类型错误不在文本里,它在类型系统的推导结果里。grep 永远看不见。
而跳转定义这类"结构查询",grep 能给出 80% 的答案,剩下 20% 的精度 换不来 LSP 那套状态维护的复杂度。
🎯 这是一个很好的"如何决定做不做一个功能"的例子。 判据不是"这个能力强不强",是**"没有它的时候,替代方案有多差"**。 面试里讲取舍,用这个框架比说"我们做了 MVP"具体得多。
🔬 sid-code 在这一点上比 Claude Code 走得更远:它有一个完整的 lsp 工具(packages/core/src/tool/lsp.ts),把 goToDefinition / findReferences / hover / documentSymbol / workspaceSymbol / callHierarchy 都暴露给模型主动调用(本 session 的工具列表里就有这个 LSP 工具)。
这是一个刻意的差异,不是抄漏了:Claude Code 的判断是"IDE 连上时能拿到完整 LSP, 所以 CLI 只要兜底诊断";sid-code 没有自己的 IDE 扩展(§8.2), 拿不到那条路径,所以把主动查询也做进了 CLI。
这个对比本身是本文最有价值的一处:同一个能力(LSP), 两个项目做的范围不同,原因不在技术判断,在各自的产品拓扑。 面试里被问"为什么 A 项目这样 B 项目那样", 能追到"因为它们的其他部分不一样",比评判谁对谁错强。
5.9 本章自检
- LSP 把 M×N 变成 M+N,对 agent 的具体意义是什么?
Content-Length数的是字节还是字符?搞错了什么时候才暴露?- agent 改了文件但没通知 LSP,失效形态是什么?为什么它特别恶劣?
waitForLSPReady为什么必须区分pending和failed/not-started?- "30 条诊断"这个阈值是怎么算出来的?
- 诊断为什么用 pull(下一轮来取)而不是 push(产生时塞进去)?
- 错误隔离为什么需要两层 try-catch?各防什么?
- 为什么内置 LSP 优先做诊断而不是跳转定义?判据是什么?
第 6 章 · remote-control:独立 Bridge 服务器
第 3 章的 Bridge 是附着式的——它挂在一个已经开着的 REPL 会话上, 把那个会话同步到云端。本章讲一个不一样的形态。
6.1 两种形态的区别
| 附着式 Bridge(第 3 章) | 独立服务器(本章) | |
|---|---|---|
| 前提 | 你已经开着一个 agent 会话 | 你根本不在电脑前 |
| 会话数 | 就是那一个 | 多个并发 |
| 进程模型 | 单进程 | 父进程 + N 个子进程 |
| 典型用法 | "把我这个会话同步到手机上" | "我出门了,机器留着给我远程用" |
| 谁创建会话 | 本地的你 | 远端的你 |
第二种形态里,本地进程变成了一个服务器:它常驻、等活、 收到请求就派生一个 agent 子进程去干、干完回收。
📄 Claude Code 的命令是 claude remote-control(别名 rc / bridge / sync)。
6.2 Bootstrap 快速路径:一个值得学的启动优化
📄 这个命令在 CLI 入口有一条专用快速路径:它不加载完整的 main.tsx(React / Ink / 全部工具 / 全部命令),只动态 import Bridge 需要的模块。
为什么值得单独讲:一个"远程控制服务器"进程不需要 TUI。 它没有终端界面要渲染(除了一个状态面板),没有斜杠命令要注册。 把这些都加载进来纯属浪费启动时间和内存。
普通启动: parse args → 加载 React/Ink → 注册工具 → 注册命令 → 起 TUI → ...
快速路径: parse args → 4 道准入检查 → 动态 import bridge → 进主循环🎯 一般化:当一个入口的运行形态和主形态差异很大时, 给它一条不经过主初始化的快速路径。 判据是"它用不到主路径里的大部件"。 代价是两条路径会各自漂移(又是 §3.3 那笔账), 所以只对差异真的很大的入口这么做。
📄 快速路径上仍然保留了四道检查(OAuth / 灰度开关 / 版本下限 / 组织策略) ——准入检查不能因为走快速路径就省掉。这是对的: 省掉的是 UI,不是安全。
6.3 ★ 三种派生模式:并发会话怎么不互相踩
这是本章最有实质内容的一节。多个会话同时在一台机器上跑, 它们会改同一个 git 仓库的文件。怎么隔离?
📄 三种模式:
① single-session —— 一次性
┌────────┐
│ Bridge │ ── spawn ──▶ 1 个 agent 子进程
└────────┘ 会话结束 → Bridge 自己也退出
适用:跑一个任务就走
② worktree —— 每个会话一个独立 git worktree
┌────────┐ ┌─ agent(worktree-1/)
│ Bridge │ ── spawn ──▶ ├─ agent(worktree-2/)
│ 常驻 │ └─ agent(worktree-3/)
└────────┘ 文件完全隔离
适用:多个任务并发,互不干扰
③ same-dir —— 全部共享同一个目录
┌────────┐ ┌─ agent(cwd/)
│ Bridge │ ── spawn ──▶ ├─ agent(cwd/)
│ 常驻 │ └─ agent(cwd/)
└────────┘ ⚠️ 会互相覆盖改动
适用:快速原型,明知有风险为什么 worktree 是正确的隔离手段
git worktree 是同一个仓库的多个工作目录,它们共享 .git (对象库、历史全都是一份),但各自有独立的工作区文件和独立的 HEAD。
my-project/ ← 主工作区,.git 在这里
.git/ ← 一份,共享
src/
.worktrees/
session-a/ ← 独立工作区,独立分支
src/
session-b/
src/为什么这个方案好:
| 需求 | worktree 满足吗 |
|---|---|
| 文件改动互不影响 | ✅ 各自独立的工作区 |
| 磁盘开销可接受 | ✅ .git 共享,只多一份工作区文件 |
| 能各自 commit / 切分支 | ✅ 独立 HEAD |
| 创建/销毁成本低 | ✅ git worktree add / remove,秒级 |
替代方案为什么更差:
- 完整 clone 三份 → 磁盘开销 ×3(大仓库几个 GB),且历史不共享
- 容器隔离 → 更重,而且要解决"容器里怎么访问宿主的 git 凭据"
- same-dir → 就是不隔离
🔬 本仓自己就用 worktree 做并行任务隔离(CLAUDE.md 有一整节讲 "在 worktree 里跑门禁"的坑)。而且踩到过一个非常值得记的教训: worktree 默认没有自己的
node_modules,依赖会向上解析到主仓, 于是本次新增的导出在那里不存在——症状是make build打出<新函数名> will always be undefined,而构建仍然 exit 0。这是"隔离不彻底"的经典形态:你以为隔离了, 但有一条路径悄悄穿透回了共享状态。 worktree 隔离的是工作区文件, 不隔离依赖解析。
三种模式怎么选
判据是**"会话之间会不会改同一个文件"**:
| 场景 | 选哪个 |
|---|---|
| 一次一个任务 | single-session(最简单,用完即走) |
| 多任务并发、都要改代码 | worktree |
| 多任务并发、但都是只读(问问题、看代码) | same-dir 可以(省掉 worktree 开销) |
6.4 会话生命周期:SessionHandle
📄 子进程管理封装成一个 SessionHandle,它的字段清单本身就是一份 "管子进程要管什么"的checklist:
| 字段 | 为什么需要 |
|---|---|
sessionId | 标识 |
done: Promise<Status> | 等它结束、拿到结果 |
kill() / forceKill() | 两级终止:先礼后兵 |
activities[] | 最近约 10 条活动的环形缓冲 |
currentActivity | 状态面板显示"它正在干什么" |
lastStderr[] | 最近几行 stderr,崩溃时的诊断依据 |
writeStdin(data) | 往子进程发输入 |
updateAccessToken(t) | token 会过期,要能热更新 |
三个细节值得展开:
① 为什么要两级终止(kill 和 forceKill)
kill() 发 SIGTERM,给子进程机会保存状态、关文件、清理临时目录。 但子进程可能卡住不响应。forceKill() 发 SIGKILL,操作系统直接结束它。
只有 SIGKILL 会丢数据,只有 SIGTERM 会卡住。 所以两个都要有, 顺序是"先 TERM,等一小会儿,还没死就 KILL"。
② 为什么 stderr 要留最近几行而不是全部
全留 = 一个跑了三天的会话,stderr 缓冲可能有几百 MB。 一行都不留 = 崩溃时你不知道为什么。
留最近 N 行是唯一合理的选择——因为崩溃原因通常就在最后几行。 数据结构又是环形缓冲(和 §3.6 的去重集、activities 是同一个模式)。
这个模式在本章出现了三次(去重集 / activities / stderr): 长生命周期进程里任何"记录历史"的结构,都必须有界。 无界的历史记录 = 定时 OOM,而且只在长跑时暴露。
③ 活动追踪:从 stdout 反推"它在干什么"
📄 父进程解析子进程的 stdout,把工具调用变成人类可读的摘要: "Editing src/foo.ts" / "Running tests"。这些显示在状态面板上。
这是一个有意思的设计:父进程并不理解 agent 的内部状态, 它只是从输出流里提取信号。这样父子之间不需要额外的状态上报协议。
代价是耦合了输出格式——子进程改了输出格式,父进程的解析就悄悄失效 (状态面板显示"空闲",其实在忙)。这类耦合是静默失效的, 第 9 章会把它归到"约定型失效"那一类。
6.5 状态面板与 QR 码
📄 一个终端 UI,显示连接 URL、仓库/分支、会话列表和每个会话的当前活动。 细节里有两个值得注意:
- QR 码 —— 手机直接扫码连接。因为这个场景的典型用户就是"人要出门用手机", 让他手动在手机上敲一个带 token 的长 URL 是很糟的体验。
- OSC 8 终端超链接 —— 支持的终端里 URL 可以直接点击。 这是一个终端转义序列标准,成本几行代码。
这两个都是"想清楚了用户在什么场景下用它"的产物。 QR 码尤其:它不解决任何技术问题,纯粹解决"跨设备传一个长字符串"这个人的问题。
6.6 会话标题:一条六级优先级链
📄 远端的会话列表要显示标题。推导链是:
1. 用户显式命名 claude remote-control "Fix login bug"
2. /rename 命令 用户在会话里改名
3. 第 1 条消息推导 deriveTitle(firstMessage) → 占位标题(立刻有)
4. 第 1 条消息 LLM 生成 用小模型生成 → 异步替换占位
5. 第 3 条消息重新生成 用更完整的对话再生成一次 → 更准
6. 随机 slug 兜底 "remote-control-graceful-unicorn"三个设计点:
① 为什么第 3、4 级要分开(先占位再升级)
因为 LLM 生成要一两秒。这一两秒里会话列表得显示某个东西。 先用规则截一个占位标题(立刻可用),LLM 好了再替换。
这是"先给一个不完美但立刻可用的答案,再异步升级"的模式。 用户宁可看到一个粗糙标题然后它变好,也不愿意看到几秒空白。
② 为什么第 3 条消息要重新生成
第 1 条消息可能是"帮我看看这个"——生成不出有意义的标题。 到第 3 条时话题已经明确了。
③ 为什么用最小的模型生成标题
标题生成是装饰性的、低优先级的。用大模型是浪费,而且更慢。 📄 Claude Code 用 Haiku(<1s)。
⚠️ 这类"辅助调用"在成本核算上是最容易漏计的一块—— 它绕过主埋点(本仓 CLAUDE.md「更省」那节明确列了 side-call 成本这一项)。 一个会话可能有 2-3 次标题生成,看起来微不足道, 但乘以会话总数之后它是一条真实的成本线。
🔬 sid-code 没有 remote-control 独立服务器形态(见 §8.3), 但它有会话标题生成,走的是同一个思路。
6.7 本章自检
- 附着式 Bridge 和独立服务器,前提差别是什么?
- worktree 隔离了什么、不隔离什么?(后者有真实事故)
- 为什么要两级终止(SIGTERM 然后 SIGKILL)?
- "从子进程 stdout 反推活动"买到了什么、代价是什么?
- 标题为什么要"先占位再异步升级"?
第 7 章 · Chrome 集成:为什么只能用 Native Messaging
这一章很短,但它是一个**"平台限制决定架构"** 的干净例子。
7.1 想干什么
让 agent 能操作浏览器:调试网页、看控制台报错、截图、填表单。
7.2 为什么不能用前面任何一种方案
回顾一下我们已有的手段:
| 手段 | 能用吗 | 为什么 |
|---|---|---|
| 让扩展开一个端口,agent 连上去(像 IDE 那样) | ❌ | 浏览器扩展不能监听端口。 这是 Chrome 的安全模型硬限制 |
| 扩展主动连 agent 开的 WebSocket | ⚠️ | 技术上可以(扩展能发出连接),但要求 agent 先开端口并让扩展知道端口 |
| stdio | ❌ | 扩展不是 agent 的子进程 |
Chrome 给扩展留的唯一本地通信出口叫 Native Messaging:
扩展可以请求浏览器启动一个本地程序,然后通过那个程序的 stdin/stdout 和它通信。
注意主语:是浏览器启动那个程序,不是扩展直接连它。 而且要事先在系统的某个目录放一份 manifest 文件, 声明"这个扩展 ID 允许启动这个可执行文件"——双向白名单。
7.3 于是架构必须是四层
📄 Claude Code 的形态:
┌──────────────┐ Native Messaging ┌──────────────────┐
│ Chrome 扩展 │ ◀═══ stdin/stdout ═▶│ Native Host │
│ │ (4 字节长度前缀) │ (浏览器派生的子进程)│
└──────────────┘ └────────┬─────────┘
│ Socket / Pipe
▼
┌──────────────────┐
│ MCP Server │
└────────┬─────────┘
│ MCP
▼
┌──────────────────┐
│ agent (MCP Client)│
└──────────────────┘为什么中间要多一个 MCP Server 层:因为 Native Host 是 浏览器的子进程——它的生命周期由浏览器决定,agent 管不着。 而 agent 需要一个稳定的连接目标。所以 Native Host 只做协议转换, 真正的服务端在它后面。
这四层不是过度设计,是平台限制逼出来的最短路径。
7.4 值得记住的一点
同一个需求("连一个外部程序"),在三个平台上有三个完全不同的答案:
平台 允许的机制 谁是监听方 IDE(VS Code) 扩展可以开本地端口 扩展 浏览器(Chrome) 只能 Native Messaging 无监听方(浏览器派生子进程) 语言服务器 就是个普通程序 无监听方(agent 派生子进程) 架构差异的成因不是设计品味,是宿主平台给了什么口子。
🎯 面试里如果被问"为什么 IDE 集成和 Chrome 集成架构不一样", 这张表就是答案。能指出"是平台能力矩阵决定的,不是我们的选择", 比试图给出一个统一的架构理论要准确。
🔬 sid-code 没有 Chrome 集成(全仓无 claudeInChrome / Native Messaging 对应实现)。
第 8 章 · sid-code 实测现状:哪些真跑得起来
本章全部是 🔬 源码实读(2026-09-03)。这是本文和一份"照抄 spec 的对标文档" 最大的区别:下面每一条我都在仓库里打开文件、跑 grep 核过。
⚠️ 同目录下另有一份 2026-08-15 的对标文档 (
bugfixes/todo/对齐claude-code-缺口修复方案/15-CC「15. IDE 集成」对标结果.md)。 本章不是它的复述——本次实读发现它的部分结论已经不准了, 冲突处以本章为准,并在 §8.5 明确列出差异。
8.1 代码量与接线全景
先看规模,再看接线状态。"有代码"和"跑得起来"是两件事,这一节把它们分开。
| 层 | 目录 | 行数 | 生产接线点 | 真跑得起来? |
|---|---|---|---|---|
| IDE 集成 | packages/core/src/ide/(10 文件) | 943 | cli.ts:2132、command/ide.ts | ⚠️ 部分 |
| Bridge | packages/core/src/bridge/(8 文件) | 921 | cli.ts:2558 → app.ts:6016 | ✅ 是 |
| LSP | packages/core/src/lsp/(10 文件) | 1943 | cli.ts:2144、query/loop.ts:187、tool/lsp.ts | ✅ 是 |
这张表本身就纠正了一个常见误判:三层里 LSP 是最大的一块(1943 行, 超过另外两层之和的一半),而它也是接线最完整的一层。
8.2 IDE 集成:骨架完整,增强能力断线
✅ 真跑得起来的部分
| 能力 | 证据 |
|---|---|
| lockfile 发现 + 过期清理 | ide/lockfile.ts(91 行),cleanupStaleLockfiles 检查 PID |
| 轮询发现(30s,环境变量 + 工作区两条匹配) | ide/detect.ts:45-68 |
| 注册为动态 MCP Server | ide/integration.ts:99 调 mcpManager.addServer |
| 启动时自动连接 | cli.ts:2132 拿 getIDEIntegration |
/ide 命令(status / connect / disconnect / install) | cli/src/command/ide.ts:23-28 四个子命令 |
| 扩展安装(vscode / cursor / windsurf) | ide/extension-install.ts(70 行) |
❌ P0-1:diff 展示是死代码(最重要的一条)
这一条是本章最值得看的,因为它是一个教科书级的"死接线"样本。
三层调用链,最外面那层没人调:
edit.ts / write.ts(编辑工具)
│
✗ ← 断在这里:零 import
│
ide/tool-hooks.ts showEditDiffInIDE / cleanupIDEDiffTabs
│
↓ ✅ 有调用
ide/diff.ts showDiffInIDE / closeAllDiffTabs
│
↓ ✅ 有调用
ide/rpc.ts callIDERpc → MCPgrep 实证(附录 A 有可复跑命令):
$ grep -rn "tool-hooks" packages --include="*.ts" --include="*.tsx" | grep -v node_modules
(0 行)
$ grep -rn "ide\|IDE" packages/core/src/tool/edit.ts packages/core/src/tool/write.ts
packages/core/src/tool/edit.ts:453: usageGuide(): string { ← 只是碰巧含 "ide" 三个字母
packages/core/src/tool/write.ts:109: usageGuide(): string {第二条 grep 的结果本身很有意思:edit.ts / write.ts 里唯一含 "ide" 的地方 是 usageGuide 这个单词里的 "ide"。编辑工具对 IDE diff 一无所知。
而 tool-hooks.ts 自己的注释(:5-11)已经把这个断层写下来了:
「本模块提供可被调用的 diff 展示函数,由持有 old/new 内容的一方 (编辑工具或其上层)显式调用」
那个"调用方"就是没实现。 注释准确地描述了设计意图, 而设计意图和现实之间的差就是这个 bug——注释读起来完全正常, 所以没人会从注释里发现问题。(对比 §3.5 那个案例:那里注释是错的, 这里注释是对的但描述的是一个未完成的计划。两种都会误导读者。)
❌ P0-2:通知方法名带了多余前缀 → 选区 / @提及 永久静默
🔬 sid-code 订阅的名字:
ide/selection.ts:47 "notifications/selection_changed"
ide/mention.ts:46 "notifications/at_mentioned"📄 而 Claude Code 扩展实际发的 wire 名是不带前缀的 selection_changed / at_mentioned(chapter-16 记载 z.literal('selection_changed') 与 NOTIFICATION_METHOD = 'at_mentioned')。
🔬 而 sid-code 的 MCP 客户端直接把 method 字符串当路由 key (mcp/client.ts:139-145,notificationHandlers.get(method)), 没有任何前缀归一化。
所以这两个订阅永远匹配不上。 而且:
- 不会报错
- 不会有日志
/ide status会显示"✓ 已连接"- 只是选区永远是空的
这个失效形态同时具备"连接正常"和"功能全无"两个特征, 是本文第 9 章"绿着坏掉"的头号样本。
为什么会写出这个前缀:因为 MCP 标准通知确实带 notifications/ 前缀 (notifications/message、notifications/progress)。 写代码时按标准补齐前缀是很自然的动作。 但 IDE 的这两个通知不是 MCP 标准通知,是 IDE 扩展的私有约定—— 它们只是借道 MCP 的通知通道。
这正是 §1.7 那个论证的现实版:复用协议的代价,就是被接入方有协议外的私有扩展时, 你会在一个不为它设计的抽象上做适配,而失配是静默的。
❌ P0-3:diff RPC 的参数名与响应格式和 CC 扩展不兼容
🔬 sid-code 发的(ide/diff.ts:50-55)vs 📄 CC 扩展期望的:
| 🔬 sid-code | 📄 CC 扩展 | |
|---|---|---|
| 参数 | filePath, oldContent, newContent, tabId | old_file_path, new_file_path, new_file_contents, tab_name |
| 响应 | {status, content} 对象 | 数组 [{type:'text', text:'FILE_SAVED'}, ...] |
| 关标签 | 只有 closeAllDiffTabs | 单个 close_tab + closeAllDiffTabs |
命名风格都不一样(camelCase vs snake_case),所以不是个别字段错, 是整套协议没对齐。
但要注意这一条的实际优先级:因为 P0-1 让 diff 根本没被调用, P0-3 目前不产生任何可观察的症状。它是一个"等 P0-1 修好之后才会暴露"的 bug。
🎯 这个关系值得单独讲:两个 bug 叠在一起时, 上游的死接线掩盖了下游的协议不兼容。 修 P0-1 的人如果只验证"函数被调到了",会以为修好了—— 实际上 diff 会全部返回 error。
修死接线时,验收判据必须是"功能真的产生了正确效果", 不能是"调用发生了"。 这和本仓那条铁律同源: 新增防线的验收判据是「真实会话里被触发过」,不是「build 过 + 单测过」。
⚠️ P1:发现逻辑的健壮性缺口(四条)
| 缺的 | 实证 | 后果 |
|---|---|---|
| 端口探活 | grep createConnection|checkIdeConnection packages/core/src/ide/ → 0 命中 | PID 活着但端口不响应的 lockfile 清不掉,会去连一个不听的端口 |
| NFC 路径归一化 | grep 'normalize("NFC")' packages/core/src/ide/ → 0 命中;detect.ts:80-84 的 isSubPath 是裸字符串比较 | macOS 用 NFD、VS Code 用 NFC,带重音符或部分 CJK 的路径工作区匹配失败 |
| WSL 路径转换 | types.ts:17 有 runningInWindows 字段,全仓无对应转换实现 | WSL 里跑 agent + Windows 上开 IDE → 固定用 127.0.0.1,连不上 |
| PID 祖先链检查 | detect.ts 只有环境变量 + 工作区两条匹配 | 多 IDE 窗口工作区重叠时直接放弃(甩给手动选择) |
第二条(NFC)值得多说一句,因为它是最典型的边界静默失败:
café/src这个路径,macOS 文件系统存的是cafe+ 组合重音符(NFD,5 个码位), VS Code 报的是café(NFC,4 个码位)。两个字符串肉眼完全一样,===返回 false。后果:路径里有重音符/某些 CJK 组合字符的用户,IDE 发现永久失败, 而且日志里打出来的两个路径看起来一模一样。
第四条不是 bug,是一个刻意的降级(回顾 §2.2:多匹配时放弃比猜错好)。 它排在这里是因为体验差一档,不是因为错。
⚠️ P1:没有发送 ide_connected 通知
🔬 grep -rn "ide_connected" packages → 0 命中。
📄 CC 连上后会立刻给 IDE 发 ide_connected(带 {pid}),让 IDE 侧知道 是哪个 CLI 进程连上来了,从而显示连接状态。
代价是 IDE 侧的状态指示器不工作——用户在 IDE 里看不出连上了没。 修它大概是加几行。
🟢 P2:结构性缺口(不是偷懒)
- 不支持 JetBrains ——
extension-install.ts只有三个 VS Code 系 - 没有基于进程的 IDE 检测 —— 做不了"IDE 开着但扩展没装"的引导
- 没有
diffTool配置开关 —— 反正 diff 是死代码 - 没有 URI handler / Tab 徽章 / 会话改名等扩展侧 UI
最后一条是根因,前面几条都从它派生:
🔬 sid-code 没有自己的 IDE 扩展本体。 (
packages/core/src/extension/是 skill/agent 的 Markdown loader,与此无关。)
这让 IDE 这一层处在一个尴尬位置:它现在唯一可能连上的扩展是 Claude Code 官方的 anthropic.claude-code,所以 P0-2 / P0-3 必须"改成 CC 兼容"才有意义。 但 CC 的扩展并不承诺兼容第三方 CLI,随时可能改协议。
所以这一层的战略前提没定,三个方向各有代价:
| 方向 | 买到 | 代价 |
|---|---|---|
| 兼容 CC 扩展 | 零扩展开发成本,立刻能用 | 协议随时可能被上游改掉,且我们无从得知 |
| 自研扩展 | 完全可控,能做 CC 没有的 UI | 要维护 VS Code + JetBrains 两套扩展,是一个独立产品 |
| 放弃 IDE 层 | 省下全部维护成本 | 943 行代码变成纯负债 |
8.3 Bridge:真接线了,但只有一代形态
✅ 真跑得起来
接线链完整(这一点和 2026-08-15 那份文档的印象不同,见 §8.5):
cli.ts:309 --bridge <url> / --bridge-token 参数
↓
cli.ts:600-601 解析进 cliArgs.bridgeUrl / bridgeToken
↓
cli.ts:2558-2564 if (cliArgs.bridgeUrl) → app.runBridge({url, authToken})
↓
app.ts:6016-6042 动态 import BridgeRunner,注入 4 个依赖,常驻等信号
↓
bridge-runner.ts BridgeCore + PermissionProxy + WS transportapp.ts 的注释说得很准确:Bridge 是「与 runHeadless / runTUI 平级的第三种运行形态」。
| 能力 | 证据 |
|---|---|
| WS 传输 + 心跳(30s) | ws-transport.ts:15,152-158 |
| 指数退避重连 + 抖动 + 10 分钟预算 | ws-transport.ts:183-208 |
| 消息去重(环形缓冲,容量 10000) | message-dedup.ts |
| 串行批量上传 + 背压 | serial-batch-uploader.ts(批 500 / 队列 10000) |
| 权限代理(60s 超时,fail-closed) | permission-proxy.ts:29,42-46 |
| 串行消费远程消息(一次一轮) | bridge-runner.ts:118-124 |
| 依赖反转(不反向依赖 app.ts) | bridge-runner.ts:32-56 的 BridgeRunnerDeps |
最后一条值得表扬:BridgeRunnerDeps 用四个回调 (submitMessage / setStreamTextCallback / abort / setPermissionDelegate) 把 core 层的 Bridge 和 CLI 层的 App 解耦。core 不 import app, 所以 Bridge 能被单测(tests/bridge/bridge-core.test.ts 用 fake transport)。
⚠️ 与 CC 的能力差距
| 缺的 | 实证 | 后果 |
|---|---|---|
| 只有 WS 一种传输 | transport.ts:12-17,非 ws:// 直接抛错 | 企业代理不支持 WS 时无回落 |
| 不区分永久失败码 | ws-transport.ts:84 只判 code !== 1000 | 认证失败也重试满 10 分钟 |
| 心跳只有保活没有探活 | 无 pong 超时判定(§4.4) | 半开连接检测不到,isConnected() 说谎 |
无 droppedBatchCount | types.ts:23-44 接口里没有 | 静默丢包不可观测 |
| 无丢弃上限 | serial-batch-uploader.ts:85-92 只退避不放弃 | 极端故障下永久卡在一批上 |
| 权限响应是布尔的 | permission-proxy.ts 返回 Promise<boolean> | 远端不能改工具输入、不能更新权限规则(§3.7) |
| 无 Bridge Pointer | 全仓无对应实现 | 崩溃后不能恢复会话 |
| 无准入检查链 | runBridge 直接连 | 没有灰度/组织策略/版本下限门(§3.4) |
| 无 remote-control 独立形态 | 全仓无 remote-control 命令 | 不支持多会话并发(第 6 章整章) |
其中"无准入检查链"这一条要看清语境再判断严重性:CC 的九道门里 有一半是它自己的运营需要(服务端灰度、组织策略、版本下限), sid-code 是自托管、URL 由用户自己给的,这些门本来就不适用。
但有两条是通用的、值得补的:
- 超时/失败的跨进程退避(§3.4⑥)—— 只要有"重复启动都会重演的失败",这个模式就有价值
--bridge是不是该有一层确认 —— 这个参数一开就意味着"接受远端指令执行本地命令"。 🔬 目前没有任何交互确认,给了 URL 就直接连。对一个高危能力, 这是一个真实的安全缺口(§9.13 会展开)。
⚠️ 白名单与真实转发路径不一致
见 §3.5 那个方框:isEligibleForBridge() 有单测且全绿、 bridge-runner.ts:10 的注释声称它在链路上、生产调用点 0 个。
8.4 LSP:三层里做得最完整的
✅ 真跑得起来(而且部分超过 CC)
| 能力 | 证据 |
|---|---|
| Content-Length 分帧(正确处理字节数) | lsp/client.ts:174-208,用 Buffer.from 算字节 |
| 三层架构 Client / Instance / Manager | 三个文件 289 / 211 / 168 行 |
| 状态机 + 崩溃重启(上限 3 次) | server-instance.ts:5-6,189-201 |
restartsExhausted 暴露为可查询状态 | server-instance.ts:47-48 → manager.ts:120 健康警告 |
懒初始化 + 四态 + waitForLSPReady 只等 pending | manager.ts:16,246-253 |
| 诊断限流(每文件 10 / 总计 30 / LRU 500) | diagnostic-registry.ts:17-19 |
| 按严重度排序后截断 | diagnostic-registry.ts:310 |
| 两层错误隔离 + 部分成功建模 | passive-feedback.ts:32-63 返回 {successCount, errors} |
| 子代理诊断隔离(防"互相偷诊断") | manager.ts:182-183 |
| 诊断带 error code(TS2304 等) | passive-feedback.ts 的 formatDiagnostics |
| 主动 LSP 查询工具(📄 CC 无此形态) | tool/lsp.ts:定义/引用/hover/符号/调用层级 |
最后两条是 sid-code 超出 CC 的部分:
- 带 error code —— 让模型能按错误码检索文档,而不只是读错误文本。 这个细节的价值:
TS2304是可搜索的,"Cannot find name 'foo'" 也可搜但精度低。 - 主动查询工具 —— §5.8 讲过这是产品拓扑差异(CC 有 IDE 扩展兜底,sid-code 没有), 不是抄漏。
⚠️ 差距
| 缺的 | 实证 | 后果 |
|---|---|---|
reinitializeLSP() 零生产调用 | manager.ts:265 定义,全仓除自身外 0 命中;/reload-plugins(command/plugin.ts)不碰 LSP | 改了 .sid-code/lsp.json 后无法在会话内生效,只能重启 |
| 无 bare / print 模式跳过 | cli.ts:2144-2145 无 config.print 门控 | 无头与 Bridge 模式下也会起 LSP,白付索引开销 |
⚠️ 一处自我更正:本文初稿在这里写了「无代数计数器(generation counter)」。 那是错的 ——
manager.ts:21有initGeneration, 且:44、:56用currentGen !== initGeneration正确地让过期初始化短路。 错因是我第一次 grep 用了大小写敏感的generation,漏掉了initGeneration。留着这条更正是刻意的:它正是附录 A.8 第 ② 条 「grep 的命中数不等于事实」的又一个样本—— 而且这次栽的是大小写,不是分类。声称"某机制不存在"比声称"存在"更容易错, 因为前者依赖于"我搜的关键词覆盖了所有可能的命名"这个无法自证的前提。
8.5 ⚠️ 与 2026-08-15 那份对标文档的差异
这一节专门列出来,因为引用一份过期结论的代价很高。
| 那份文档说 | 🔬 2026-09-03 实读 | 判定 |
|---|---|---|
| P0-1 diff 是死代码 | ✅ 仍然是(tool-hooks 零 import) | 仍然成立 |
| P0-2 通知名不兼容 | ✅ 仍然是(notifications/ 前缀还在) | 仍然成立 |
| P0-3 diff RPC 不兼容 | ✅ 仍然是 | 仍然成立 |
| P1 四条健壮性缺口 | ✅ 全部仍然是(NFC / 探活 / WSL / 祖先链,各 0 命中) | 仍然成立 |
P1 无 ide_connected | ✅ 仍然是(0 命中) | 仍然成立 |
| 「IDE 功能目前是所有功能都跑不起来」 | ⚠️ 过强。发现→连接→MCP 注册→/ide 命令这条主链是通的,/ide status 能用;不通的是三个增强能力(选区 / @提及 / diff) | 需要收窄 |
| 那份文档没有覆盖 Bridge 层 | 🔬 Bridge 已完整接线(cli.ts:2558 → app.ts:6016),是第三种运行形态 | 新信息 |
| 那份文档没有覆盖 LSP 层 | 🔬 LSP 1943 行,接线最完整,且有 CC 没有的主动查询工具 | 新信息 |
最后三行是本章存在的理由:那份文档的标题是「IDE 集成对标结果」, 范围只有 IDE。如果拿它当"IDE & Bridge 全景"引用, 会得出"这一整块都不行"的错误结论——而实际上三层里两层是好的。
🎯 这本身是一条方法论教训(本仓 CLAUDE.md 自检第 4 问): 引用"现状"时必须回源码核过,不能照抄文档。 文档会过期,而且过期的方向通常是"比现实更悲观"—— 因为文档写的是发现问题的那一刻,之后的修复不会自动回写。
8.6 优先级建议
按"成本 ÷ 收益"排(不含"是否自研扩展"这个战略问题):
| # | 做什么 | 成本 | 收益 |
|---|---|---|---|
| 1 | 去掉 notifications/ 前缀(P0-2) | 改 2 行 | 选区 + @提及从"永久静默"变成可用 |
| 2 | 加 ide_connected 通知(P1) | 几行 | IDE 侧状态指示器复活 |
| 3 | diff RPC 改 CC 兼容(P0-3) | 小 | 为 #4 铺路 |
| 4 | diff 接进 edit/write 权限流(P0-1) | 中 | 交互式 diff 从零到有。验收判据必须是"真实产生了正确 diff",不是"函数被调到" |
| 5 | 让 forwardEvent 真的用 isEligibleForBridge(§3.5) | 小 | 消除双判断漂移 |
| 6 | 心跳加 pong 超时(§4.4) | 小 | 半开连接可检测 |
| 7 | 永久失败码不重试(§4.3) | 小 | 认证错立刻报,不拖 10 分钟 |
| 8 | NFC 归一化 + 端口探活(P1) | 小 | 消除两类边界静默失败 |
| 9 | --bridge 加确认或策略门(§9.13) | 小 | 高危能力的准入 |
| 10 | JetBrains / 自研扩展 | 大 | 等战略定了再说 |
#1 和 #2 加起来不到十行,收益是"三个功能里两个从零到有"。 这是整份清单里性价比最高的一格。
第 9 章 · 陷阱库:十四个「绿着坏掉」
这一章是本文区分度最高的部分。
前八章讲的是"应该怎么做"。这一章讲做了但没生效—— 而且每一个的共同特征是:没有任何东西会变红。 编译过、测试绿、日志干净、状态显示正常,功能就是不工作。
面试里问"你踩过什么坑",能讲出这一类的人和只会讲"忘了处理 null"的人, 差距是可见的。
9.0 先给一个分类:静默失效的五种形态
十四个陷阱不是散的,它们归成五类。认出类别比记住个例更有用—— 因为你遇到的会是新的个例,但类别是重复的。
| 类别 | 机制 | 为什么测不出来 | 本章样本 |
|---|---|---|---|
| A 约定型 | 两边靠字符串/字段名约定,一方改了另一方不知道 | 双方各自的单测都绿 | 9.1、9.3、9.11 |
| B 死接线型 | 实现完成,调用方没接 | 被测的是实现,不是接线 | 9.2、9.4 |
| C 方向型 | 失败/超时的默认方向错了 | 只有失败路径才走到,测试通常只测成功路径 | 9.5、9.6 |
| D 时间型 | 涉及时钟、TTL、休眠、长跑 | 短时测试跑不到 | 9.7、9.8、9.9 |
| E 分母型 | 度量的口径/范围错了 | 数字有,只是意思不对 | 9.12、9.13、9.14 |
第 10 类如果要加一条通用判据,是这个:
凡是"两个独立的东西必须保持一致"的地方, 都需要一个会在不一致时报警的机制。 没有那个机制,不一致就是时间问题。
9.1 【A】通知方法名多了一个前缀(本文头号样本)
症状:IDE 显示已连接,/ide status 打 ✓,选区永远是空的。
机制:订阅 notifications/selection_changed, 对方发 selection_changed。🔬 ide/selection.ts:47。
为什么极难发现,四个条件同时成立:
- 连接是真的成功了(MCP 握手完成)
- 状态显示是真的正确的(确实连着)
- JSON-RPC 通知不需要回复,所以没有"等不到响应"这个信号
- 路由器找不到 handler 时什么都不做——不报错、不打日志
根因不在打字错误,在抽象泄漏:MCP 标准通知确实带 notifications/ 前缀, IDE 的私有通知不带。把 IDE 塞进 MCP 框架,就得在一个不为它设计的抽象上做适配(§1.7)。
怎么防:
- 路由器收到没有 handler 的通知时打一条 debug 日志(成本一行,收益是这类 bug 从"不可见"变成"可见")
- 或者更强:连上后主动做一次握手自检("发一个 ping 通知,看对端认不认")
- 方法名从一个共享常量文件出,两边 import 同一个,让打字错误变成编译错误
9.2 【B】diff 写完了没接线
症状:IDE 里从来不弹 diff。没有报错。
机制:showDiffInIDE ← tool-hooks.ts ← 没人。 🔬 grep -rn "tool-hooks" packages → 0。
为什么难发现:ide/diff.ts 有完整实现、有清晰注释、 返回类型把四种结果都建模了——它看起来是一段高质量的、完成了的代码。 读它的人不会想到"这段代码从没运行过"。
这个形态在本仓反复出现(记忆里 dead-wiring-has-three-boundary-forms), 三种边界形态:
| 形态 | 断在哪 |
|---|---|
| 实现完成,调用方缺失 | 本例 |
| 注册顺序反了 | 注册早于初始化 → 拿到 undefined |
| 白名单/过滤规则没被真实路径使用 | §3.5、9.4 |
怎么防:
- 新增能力的验收判据必须是"在真实运行里被触发过",不是"单测通过"。 这是本仓的明文铁律(CLAUDE.md),因为「防线自己成了死功能」已经发生过。
- 给可选增强路径加触发计数。零触发不一定是 bug(可能没人用 IDE), 但"从来零触发"是一个该去查的信号。
9.3 【A】diff RPC 协议不兼容,但被上游的死接线掩盖
症状:现在没有症状。修好 9.2 之后才会全部变成 error。
机制:参数名 camelCase vs snake_case,响应对象 vs 数组(§8.2 P0-3)。
这一条的教训是关于 bug 之间的遮蔽关系:
上游的"根本没调"掩盖了下游的"调了也不对"。 修上游的人如果只验证"函数被调到了",会宣布修好—— 然后 diff 全部返回
{action:"error"},而error分支是静默 catch 的。一个 bug 修好后暴露出下一个 bug,这是正常的; 危险的是验收判据只到"调用发生"这一层。
9.4 【B】过滤白名单有测试、有注释、零生产调用
症状:无。今天两套判断恰好一致。
机制(§3.5 完整版):isEligibleForBridge() 定义在 bridge-messaging.ts:67,被 4 处单测调用且全绿, bridge-runner.ts:10 的注释画的数据流图声称它在链路上, 而真实转发是 forwardEvent() 里的手写 switch。生产调用点 0。
三个欺骗信号叠加,这是本章最"完美"的一个陷阱:
| 信号 | 让你相信 | 实际 |
|---|---|---|
| 有单测且绿 | 这段代码被验证过 | 验证的是函数本身,不是它在链路上 |
| 注释画了数据流图 | 它在链路上 | 注释不是代码 |
| 两套判断今天一致 | 没问题 | 会各自漂移,且漂移不报错 |
记忆里那条 gate-assertions-must-read-code-not-comments 的教训是 "我自己写的注释骗过了我自己写的门禁"。这里是同一件事的另一面: 注释骗过了读代码的人。
怎么防:过滤规则只允许有一个真实入口。如果有两处判断, 让其中一处调用另一处(哪怕只是 switch (e.kind) { ... } 前面加一句 if (!isEligibleForBridge(e.kind)) return)。单一事实源不是洁癖, 是让漂移变得不可能。
9.5 【C】权限超时如果 fail-open,就是一个远程提权
症状(假想的,sid-code 这里做对了):网络抖一下,危险操作自动获批。
机制:远端权限确认要跨中继问出去(§3.7)。如果超时默认放行, 攻击面就变成"让用户 60 秒收不到通知"——断网、锁屏、地铁, 都能让任意操作自动通过。
🔬 sid-code 这里是对的:permission-proxy.ts:42-46 超时 resolve(false)。
但本仓有过反例:记忆里 proxy-port-root-fix-direct-plus-launchd 记录过一个在途容器守卫 fail-open——探测命令失败时, 输出被当成"没有容器,可以放心重启"。最该拦的时候放行。
判据:
安全判断的默认方向永远朝"拒绝"倒。 而且"探测失败"必须和"探测成功且结果为空"区分开—— 这两个在代码里经常长得一样(都是空字符串 / 空数组), 而语义完全相反。
这一条是本章最重要的一条,因为它的后果是安全事故,不是功能不可用。
9.6 【C】不区分永久失败码,把 1 秒的错拖成 10 分钟
症状:token 过期了,Bridge 转圈 10 分钟才报错。
机制:🔬 ws-transport.ts:84 只判 event.code !== 1000, 所以 4001(认证失败)也进重试循环。
为什么这是错的:重试的前提是"这次失败可能是暂时的"。 认证失败不可能通过重试解决——它需要用户去重新登录。 重试 10 分钟只是把一个立刻能报出来的错, 变成10 分钟后才报的错,而且这 10 分钟里用户不知道发生了什么。
一般化判据(和记忆里 attribution-decoupled-from-signal-antipattern 同源):
判据优先级:状态码 > 数字边界 > 裸子串。 协议给了权威状态码时,必须用它分流,不能把所有失败当成同一类。
9.7 【D】长生命周期进程里,任何无界历史都是定时 OOM
症状:跑 10 分钟完美,跑三天进程被系统杀掉。
机制:Bridge / remote-control 是常驻进程。里面有至少三处"记录历史"的结构:
| 结构 | 记什么 | 无界会怎样 |
|---|---|---|
| 消息去重集 | 见过的所有消息 ID | 每条消息一个字符串,永久累积 |
| 活动列表 | 最近做了什么 | 同上 |
| stderr 缓冲 | 子进程的错误输出 | 一个刷日志的子进程能刷几百 MB |
🔬 sid-code 的去重集是有界的(BoundedUUIDSet,容量 10000)。
但有界之后出现了第二个陷阱,而且它比 OOM 更隐蔽:
容量选小了的后果不是内存问题,是重复执行。
一条早期消息被环形缓冲驱逐后,如果服务端又重放了它, 它会被当成新消息再执行一次。对于一条
user_message, 这意味着同一个指令跑两遍。所以容量的判据是:≥ 服务端可能重放的最大窗口。 这个数字必须去问服务端,不能拍。
怎么防:
- 任何"记录历史"的结构,写的时候就问自己"上限是多少"
- 长跑测试(soak test)是唯一能抓到这类问题的手段,短测试永远抓不到
9.8 【D】宿主休眠让所有耗时统计和超时判断失真
症状:耗时统计里出现一个 717 秒的操作。超时闸门看起来坏了。
机制:笔记本合盖两小时。Date.now() 前后差了两小时, 但期间什么都没发生。所有基于"开始时间 - 现在"的逻辑全部失真:
- 耗时统计:某个操作显示花了 717 秒(记忆里
host-sleep-pollutes-agent-ms-and-revives-timeout是真实事故) - 重连总预算(§4.3③):
Date.now() - reconnectStartTime直接超过 10 分钟 → 醒来就放弃 - TTL 判断:本地觉得过期了,服务端其实还留着
怎么防:
- 需要"真实经过的时间"时用单调时钟(
performance.now()),它不受墙钟跳变影响 - 长间隔的定时逻辑要能识别时间跳变(两次 tick 之间墙钟跳了远超预期的量 → 判为休眠)
- 📄 CC 的 WS 传输有专门的系统休眠检测,唤醒后立即重连而不是走退避—— 因为退避的前提("可能是服务端过载")在休眠场景下不成立
9.9 【D】双端 TTL 不对齐 → 恢复一个已被清理的会话
机制:📄 Bridge Pointer 的 TTL 是 4 小时,刻意和服务端的回收时间对齐(§3.8)。
如果本地 TTL 比服务端长:本地觉得能恢复,服务端已经清了。 用户看到一个失败的恢复——这比看到"没有可恢复的会话"更困惑, 因为它先给了希望。
判据:
双端都有过期时间时,本地必须 ≤ 服务端。 本地更短 = 偶尔多问一次"要新建吗"(可接受)。 本地更长 = 承诺了做不到的事(不可接受)。
9.10 【B/D】worktree 隔离不彻底:依赖解析穿透回主仓
症状:make build 打出 <新函数名> will always be undefined, 而构建 exit 0。
机制:🔬 worktree 隔离的是工作区文件,不隔离依赖解析。 worktree 里没有自己的 node_modules,Node 的模块解析会向上找到主仓的, 于是本次新增的导出在那里不存在。
为什么这条特别值得记:
它是"你以为隔离了,但有一条路径悄悄穿透回了共享状态"的样本。 而且构建没有失败——它只是打了一行警告然后成功了。
隔离机制的验收判据必须包括:列出所有"仍然共享"的东西。 worktree 共享
.git(这是设计意图)和node_modules解析路径(这是意外)。
9.11 【A】从 stdout 反推状态,耦合了输出格式
机制:📄 remote-control 的父进程解析子进程 stdout 提取活动摘要(§6.4)。
代价:子进程改了输出格式,父进程的解析悄悄失效。 症状是状态面板显示"空闲",其实在忙。
为什么这是 A 类(约定型):父子之间有一个没有写下来的契约 ("输出会长这个样子"),而改子进程输出的人不知道有人在解析它。
怎么防:要么用结构化输出(子进程输出 JSON 行,父进程解析字段—— 字段名比自由文本稳定得多),要么在解析失败时报警 ("连续 N 条输出都没匹配上任何模式" = 格式可能改了)。
9.12 【E】"IDE 集成不可用"这个结论的分母是错的
机制:§8.5 那条——2026-08-15 的文档说 「IDE 功能目前是所有功能都跑不起来」。
🔬 实读:发现 → 连接 → MCP 注册 → /ide status 这条主链是通的。 不通的是三个增强能力。
为什么这算陷阱:这是一个度量口径问题。 "IDE 功能"这个分母如果指"全部能力",那结论应该是"6 个能用、3 个不能用"; 如果指"增强能力",那"全不可用"是对的。分母不写清楚,结论就会被放大。
代价是真实的:拿"这一整块都不行"去做决策,可能得出"不如删掉"的结论, 而实际上删掉的是一条能用的主链。
这正是本仓 CLAUDE.md 那条铁律:分母比分子重要。 以及自检第 4 问:引用"现状"要回源码核过,不能照抄文档。
9.13 【E】高危能力没有准入门,且"有防线"和"防线被触发过"是两件事
机制:🔬 --bridge <url> 一开就意味着"接受远端指令在本地执行命令"。 目前没有任何交互确认、没有策略门(§8.3)。
📄 对比 CC 的九道准入检查(§3.4)。虽然其中一半是它的运营需要(灰度、组织策略), 但"这是一个允许远程执行本地命令的开关"这个事实两边一样。
更深一层的陷阱在度量上:
假设明天加了一道确认门。你怎么知道它真的在工作?
用"事故数"当指标是没用的——安全是"坏事没发生", 负面事件天然稀疏,分母恒 0,曲线恒平, 分不清"防线起作用"和"运气好"。
🔬 本仓的做法是换成正面信号:
scripts/defense-trigger-rate.ts量触发率。 而且实测出过一个漂亮的结果:审计类任务 0% 触发—— 即「防线全在、调用全 0」。防线自己成了它当初要消灭的死功能。
所以这一条同时是 E 类(分母)和 B 类(死接线):
"加了防线"和"防线被触发过"是两个完全不同的事实, 而只有后者能证明它有效。
9.14 【E】side-call 成本绕过主埋点
机制:§6.6 的标题生成用小模型。一次会话可能 2-3 次。
为什么会漏计:这些辅助调用(标题、摘要、recall)走的是影子路径, 不经过主对话的埋点。单看一次微不足道,乘以会话总数是一条真实的成本线。
🔬 本仓 CLAUDE.md 的「更省」那节明确把 side-call 成本列为"最易漏计的一块"。
判据:任何"顺手调一下模型"的地方,都要问一句 "这次调用进账本了吗"。没进的话,你的成本曲线就是系统性偏低的—— 而系统性偏低的成本数据比没有数据更危险,因为你会信它。
9.15 把十四条折成六条可执行的检查
面试或做设计评审时,这六条比记住十四个例子有用:
| # | 检查 | 对应陷阱 |
|---|---|---|
| 1 | 任何"两边必须一致"的约定,有没有一个会在不一致时报警的机制? | 9.1、9.3、9.11 |
| 2 | 新增的能力,在真实运行里被触发过吗?(不是单测过) | 9.2、9.4、9.13 |
| 3 | 失败和超时的默认方向是"拒绝"吗?"探测失败"和"结果为空"分开了吗? | 9.5、9.6 |
| 4 | 长跑会怎样?无界结构有吗?休眠会打乱什么?双端 TTL 对齐了吗? | 9.7、9.8、9.9、9.10 |
| 5 | 这个数字的分母写清楚了吗? | 9.12 |
| 6 | 降级/丢弃/辅助调用有计数吗? | 9.13、9.14、§4.6 |
🎯 面试用法:被问"你会怎么保证这套东西的质量", 别答"写单测 + code review"。按这六条答,每条给一个具体失效形态。 第 2 条和第 3 条区分度最高。
第 10 章 · 横向对比:同一个能力,三种目的
这一章的目的不是排名。同一个能力在不同项目里做到不同深度, 原因通常不在技术判断,在各自的产品拓扑和商业约束。 能追到那一层,才算真的看懂了对比。
10.1 三层能力矩阵
| 能力 | 📄 Claude Code | 🔬 sid-code | 差异的成因 |
|---|---|---|---|
| IDE 发现(lockfile) | ✅ + 端口探活 + NFC + 祖先链 | ✅ 骨架,缺三项健壮性 | 打磨深度(用户量差几个量级) |
| IDE 扩展本体 | ✅ 自研(VS Code + JetBrains) | ❌ 无 | 战略未定,是下面多条的根因 |
| 选区 / @提及 | ✅ | ❌ 方法名失配,永久静默 | 一个两行的 bug(§9.1) |
| 交互式 diff | ✅ 接进编辑权限流 | ❌ 死代码 | 死接线(§9.2) |
| Bridge 传输 | 三代并存(WS / Hybrid / SSE+POST) | 一代(WS) | 企业代理兼容性的需求强度不同 |
| Bridge 准入门 | 九道 | 无 | CC 的一半门是它的运营需要(灰度/组织策略) |
| 崩溃恢复 | ✅ Bridge Pointer | ❌ | CC 的 Bridge 面向"人不在电脑前几小时" |
| 多会话 / worktree 派生 | ✅ remote-control | ❌ | 同上 |
| 权限代理 | ✅ 可改输入 + 可更新规则 | ⚠️ 只有布尔 | 远端对等性的完成度 |
| LSP 诊断(被动) | ✅ | ✅ + error code | sid-code 略深 |
| LSP 主动查询工具 | ❌(靠 IDE 扩展兜底) | ✅ 完整(定义/引用/hover/层级) | 拓扑倒置(见 §10.2) |
| Chrome 集成 | ✅ | ❌ | 优先级 |
10.2 ★ 最有意思的一格:LSP 的拓扑倒置
矩阵里唯一一处 sid-code 明显超过 CC 的地方,值得单独解剖。
CC 的判断:
IDE 连上了 → 用户在 IDE 里本来就有完整 LSP(跳转、引用、补全都在手边)
→ CLI 只需要兜底一件 IDE 给不了 agent 的事:把诊断喂给模型
→ 所以内置 LSP 只做被动诊断sid-code 的处境:
没有自己的 IDE 扩展 → 拿不到那条路径
→ 模型想知道"这个函数被谁调了",只能 grep(精度不够)
→ 所以必须把主动查询也做进 CLI结论:这不是"sid-code 更有远见",也不是"CC 抄漏了"。 是因为它们的其他部分不一样,所以这一格的正确答案不一样。
🎯 这一格是面试里最好用的一个例子。被问"为什么 A 项目有这个功能 B 项目没有", 大部分人会答"优先级不同"或者试图评判谁做得对。 能指出**"因为 B 项目的另一个部分补上了这个缺口,而 A 项目没有那个部分"** ——即能力矩阵的空格要放在整个拓扑里看——是一个明显的分析能力信号。
同一个框架还能解释另外三格:
- CC 的准入门多,是因为它要运营一个云服务(灰度、组织策略、强制升级)
- CC 的传输有三代,是因为它要穿过成千上万家公司的代理
- sid-code 没有崩溃恢复,是因为它的 Bridge 是
--bridge <url>手工启的, 场景还不是"人出门几小时"
10.3 一个必须警惕的比较陷阱
⚠️ 代码行数不是能力的度量。
| 层 | 🔬 行数 | 真实可用度 |
|---|---|---|
| IDE | 943 | 主链通,三个增强能力全断 |
| Bridge | 921 | 完整可用 |
| LSP | 1943 | 完整可用,且有超出项 |
IDE 层的行数不低,但它是三层里唯一有整块功能不工作的。 如果只看行数会得出"三层投入相当"的结论, 而实际上943 行里有一部分是永远不执行的代码。
这和 §9.12 是同一个错误的两个面:分母/口径不写清楚,数字就会误导。
第 12 章 · 术语表与学习路径
12.1 速查表(按"从近到远"排)
| 词 | 一句话 | 详见 |
|---|---|---|
| stdio | 父子进程间的三根管子。是字节流不是消息流,要自己分帧 | §1.2 |
| framing / 分帧 | 在字节流上划消息边界。LSP 用 Content-Length 头 | §1.2、§5.3 |
| 端口 | 机器上的门牌号。监听方必须先启动且被对方知道 | §1.3 |
| lockfile | 被发现方主动申报自己的一个小文件。用文件系统当服务发现 | §2.2 |
| HTTP | 一问一答,服务端不能主动说话 | §1.5 |
| WebSocket | 长期打开的双向管道。代价:代理兼容性、重连全自理、闲置被掐 | §1.5 |
| SSE | 单向长连接。真优势是 Last-Event-ID 断线续传是协议特性 | §1.5 |
| JSON-RPC | {id, method, params} 的约定。没 id 的叫通知,单向不回复 | §1.6 |
| 通知(notification) | 单向消息。method 名靠字符串约定,失配是静默的 | §1.6、§9.1 |
| MCP | Anthropic 的协议,给 agent 接外部工具。IDE 被当成一个 MCP Server | §2.1 |
| LSP | 微软的协议,把 M×N 变成 M+N。和 MCP 没关系 | §5.1 |
| diagnostics | LSP 术语,红黄波浪线。agent 用别的手段拿不到的信息 | §5.6、§5.8 |
| NAT | 路由器的地址转换。后果:外面主动连不进来 | §3.1 |
| 中继(relay) | 两端都主动连一台有公网 IP 的服务器 | §3.2 |
| Bridge | 三个所指:协议 / 运行形态 / remote-control 独立服务器 | §0.2.5 |
| 心跳 | 两个目的:保活(骗过代理)+ 探活(检测半开)。只发 ping = 只做了前者 | §4.4 |
| backoff / 抖动 | 越等越久 + 随机偏移。不带抖动会惊群 | §4.3 |
| 背压 | 队列满时让生产方等。不做就是 OOM | §4.5 |
| 半开连接 | TCP 没断但对端已死。只能靠"探测没回应"发现 | §4.4 |
| 幂等 / 去重 | 同一条消息收两次能识别。断线续传是 at-least-once,必须在接收端补 | §3.6 |
| fail-closed | 失败/超时默认拒绝。安全判断的默认方向永远朝拒绝倒 | §3.7、§9.5 |
| worktree | 同仓多工作区,共享 .git。不隔离依赖解析 | §6.3、§9.10 |
| side-call | 标题/摘要等辅助模型调用。最易漏计的成本 | §6.6、§9.14 |
| 死接线 | 实现完成、调用方缺失。测试测的是实现不是接线 | §9.2、§9.4 |
12.2 学习路径
如果你要从零实现这一套
按这个顺序,每一步都能独立跑起来验证:
① 起一个最小 JSON-RPC over stdio
派生一个子进程,用 Content-Length 分帧收发
✅ 验证:能拿到 tsserver 的 initialize 响应
⚠️ 必须用中文/emoji 测一遍分帧(字节 vs 字符,§5.3)
② 接上真的语言服务器,拿到诊断
注册 publishDiagnostics 处理器 → 限流 → 喂给模型
✅ 验证:故意写一个类型错误,模型能看到它
⚠️ 改文件后要通知 LSP,否则诊断是过时的(§5.4)
③ 做 lockfile 发现
自己手写一个假 lockfile + 一个假的 SSE 服务器当"IDE"
✅ 验证:agent 能找到并连上
⚠️ 路径带重音符/CJK 测一遍(NFC,§8.2)
④ 做一条通知(选区)
✅ 验证:**必须真的收到通知**,不是"注册没报错"(§9.1)
⚠️ 给路由器加一条"收到无 handler 的通知"的 debug 日志,第一天就加
⑤ 做一个最小中继
一个 WS 服务器,两个客户端互相转发消息
✅ 验证:一个客户端发的能被另一个收到
⑥ 加可靠性(这一步的工作量比 ①-⑤ 加起来还大)
退避 + 抖动 + 上限 + 总预算 + 区分主动关闭 + 区分永久失败码
+ 心跳(保活 **和** 探活)+ 去重 + 串行化 + 背压
✅ 验证:手动 kill 服务器、拔网线、合盖休眠,各测一遍
⑦ 加权限代理
⚠️ 超时必须 fail-closed,第一版就写对(§9.5)⑥ 的工作量占大头,这是本文最想让你记住的一件事: "能通"到"可靠"之间的距离,比"零"到"能通"远得多。
附录 A · 可复跑命令
文中每一条 🔬 都可以用下面的命令自己核一遍。 全部在
~/sid-code下执行。⚠️ zsh 用户注意:
--include=*.ts必须加引号写成--include="*.ts", 否则 zsh 会先做 glob 展开然后报no matches found。
A.1 三层代码量
wc -l packages/core/src/ide/*.ts | tail -1 # → 943 total
wc -l packages/core/src/bridge/*.ts | tail -1 # → 921 total
wc -l packages/core/src/lsp/*.ts | tail -1 # → 1943 totalA.2 验证 diff 是死代码(§8.2 P0-1 / §9.2)
# tool-hooks 的生产 import 数 —— 期望 0
grep -rn "tool-hooks" packages --include="*.ts" --include="*.tsx" \
| grep -v node_modules | wc -l
# 编辑工具对 IDE 一无所知(唯一命中是 usageGuide 里的 "ide" 三个字母)
grep -rn "ide\|IDE" packages/core/src/tool/edit.ts packages/core/src/tool/write.tsA.3 验证通知方法名前缀(§8.2 P0-2 / §9.1)
# 期望 2 —— 两处都带了多余的 notifications/ 前缀
grep -rn "notifications/selection_changed\|notifications/at_mentioned" \
packages/core/src/ide/ | wc -l
# 路由器直接把 method 当 key,无前缀归一化
sed -n '139,145p' packages/core/src/mcp/client.tsA.4 验证白名单零生产调用(§3.5 / §9.4)
# 全部命中(应为 6:1 定义 + 4 单测 + 1 注释)
grep -rn "isEligibleForBridge" packages --include="*.ts" | grep -v node_modules
# 排除单测与注释后 —— 期望只剩定义那一行
grep -rn "isEligibleForBridge" packages --include="*.ts" \
| grep -v node_modules | grep -v "tests/" | grep -v ": \*"A.5 验证四条健壮性缺口(§8.2 P1)
# 三条期望全为 0
grep -rn 'normalize("NFC")' packages/core/src/ide/ | wc -l # NFC 归一化
grep -rn 'createConnection' packages/core/src/ide/ | wc -l # 端口探活
grep -rn 'ide_connected' packages/core/src/ide/ | wc -l # 连接通知
# 裸字符串比较的 isSubPath(NFC 问题的所在)
sed -n '80,88p' packages/core/src/ide/detect.tsA.6 验证 Bridge 接线链是通的(§8.3)
grep -n "bridgeUrl" packages/cli/src/cli.ts | head -4
# → 50: 类型定义 / 600: 解析 / 2558: 路由 / 2562: 调用
sed -n '6016,6042p' packages/cli/src/app.ts # runBridge 实现A.7 核对文中引用的常量
# 重连与心跳(§4.3、§4.4)
grep -n "^const \(HEARTBEAT\|RECONNECT\)" packages/core/src/bridge/ws-transport.ts
# → HEARTBEAT_INTERVAL_MS = 30_000
# RECONNECT_BASE_DELAY_MS = 1000
# RECONNECT_MAX_DELAY_MS = 30_000
# RECONNECT_GIVE_UP_MS = 10 * 60 * 1000
# 诊断限流(§5.6)
grep -n "^const MAX" packages/core/src/lsp/diagnostic-registry.ts
# → 10 / 30 / 500
# 批量与队列(§4.5)
grep -n "maxBatchSize ??\|maxQueueSize ??" packages/core/src/bridge/serial-batch-uploader.ts
# → 500 / 10000
# 权限超时与去重容量(§3.6、§3.7)
grep -n "timeoutMs: number = " packages/core/src/bridge/permission-proxy.ts # 60_000
grep -n "capacity: number = " packages/core/src/bridge/message-dedup.ts # 10000A.8 三条核数铁律
写这类对比文档时最容易在这三处翻车:
① 分类逻辑只能有一份。 "这一层有多少行代码"和"这一层可用度如何"是两个独立问题, 不要用行数推可用度(§10.3)。943 行的 IDE 层里有一部分永不执行。
② grep 的命中数不等于调用数。 一次 grep -rn 的命中里混着:定义、单测、注释、字符串。 声称"零调用"之前必须逐条分类——§3.5 我第一版就写错了 (说"无任何调用点",实际有 4 处单测和 1 处注释, 正确说法是"零生产调用点")。
③ 文档里的"现状"会过期,而且过期方向系统性偏悲观。 因为文档写的是发现问题的那一刻,之后的修复不会自动回写。 §8.5 就是一个实例:那份 2026-08-15 的对标文档说"IDE 功能全跑不起来", 实读发现主链是通的。引用现状必须回源码核过。
本文写于 2026-09-03。所有 🔬 标记的结论在该日期实读核过; 📄 标记的内容来自 chapter-16 对 Claude Code 泄漏源码的转述,我没有原始源码。 代码会变,附录 A 给了全部复跑命令。