2026-06-28
今日主题
- Shell ANSI-C 引用与 zsh echo 行为
- venv 实现原理
- Python 包安装路径与隔离
- Python 版本与依赖隔离分层
- brew libexec 的三种使用场景
- 工具安装策略——brew vs pipx vs pip
- Durable Objects 并发与一致性模型
- DO 的 API 设计理念
- WebSocket + DO 的底层连接模型
- Durable Objects WebSocket
- AG-UI 协议命名
- Cloudflare Containers 与 DO
- MCP 客户端 Feature 体系
- SSE 协议
- LSP 协议核心机制
- LSP 与 LSIF 关系
- Git 重命名检测与展示
- Agent Client Protocol stdio 传输分帧
- Agent Client Protocol Session 机制
- ACP Proxy 工具授权桥接机制
- Claude Code Agent SDK 跨进程架构
- Claude Code SDK 控制协议
新增认知
Shell ANSI-C 引用与 zsh echo 行为
-
脉络:从 curl 命令中的 $'...' 到 zsh echo 默认解析转义:用户看到 curl 命令里用了 $'...' 语法,此前从未见过。
经解释确认这是 ANSI-C 引用后,发现 zsh 下 echo 三条命令输出一样,产生困惑。
根因是 zsh 的 echo 内置默认解析转义序列(等价于 bash 的 echo -e),导致 $'...' 和 '...' 的差异被掩盖。
最终用 printf 验证了真正的区别。 -
$'...' 是 ANSI-C 引用语法:bash/zsh/ksh 支持,POSIX sh 不支持。在 $'...' 内,
反斜杠转义序列(\n、\t、\r、\、\'、"、\xHH、\uHHHH 等)会被 shell 解析后传递给命令,而普通单引号 '...' 内所有字符原样保留。
常见用途:生成多行字符串、ANSI 颜色码、tab 分隔等。 -
zsh echo 默认解析转义,bash echo 不解析:zsh 的 echo 内置默认行为等价于 bash 的 echo -e,
会自动解析 \n、\t 等;bash 的 echo 默认只输出字面量。在 zsh 中 echo -E 可显式禁用解析,bash 中 echo -e 显式启用。
这是导致 $'...' 和 '...' 在 zsh echo 下看不清差异的原因。 -
printf 是验证 shell 层转义的正确工具:printf '%s\n' '...' 不会像 echo 那样自行解析转义序列,
因此能准确反映字符串在 shell 层的真实内容。用 printf 对比 '...' 和 $'...' 可以清晰看到前者保留字面量 \n、后者已替换为换行符。
venv 实现原理
-
脉络:venv 的隔离不是靠环境变量,是靠文件系统:从 pip 为什么要包装到 activate 到底做了什么,这一串问题的根因是同一个——
Python 解释器通过相对于自身二进制的位置找 pyvenv.cfg,而不是靠 VIRTUAL_ENV 环境变量。理解了这一点,
pip 包装(shebang 必须指向 venv 的 python)、activate 非必需(直接用 .venv/bin/python 也可)、VIRTUAL_ENV 只给工具看这几个现象就都统一了。 -
venv 共享二进制,不复制:venv 里的 python 是符号链接,指向真实 Python 二进制。
整个 venv 真正占磁盘的就是几个软链接、一个 pyvenv.cfg、空的 site-packages 目录和 pip 包装脚本,建一个 venv 毫秒级。
比 Docker 共享内核更轻量——Docker 共享内核但复制用户空间,venv 连用户空间都共享。 -
pip 包装是因为 shebang 路径:pip 本质是一个 Python 脚本,第一行 shebang 硬编码了全局 Python 的路径。
如果直接把全局 pip 符号链接到 venv 里,执行时 shebang 仍指向全局 Python,包装会到全局 site-packages。
venv 必须重新生成 pip 脚本,把 shebang 改成 venv 的 python。而 python -m pip 不依赖 shebang,
总是用当前 PATH 上的 python。 -
activate 只是 shell 便利,不参与 Python 感知:activate 只做三件事:
把 .venv/bin 加到 PATH 最前面、设 VIRTUAL_ENV 环境变量、改 shell 提示符。
不 activate 直接用 .venv/bin/python 或 .venv/bin/pip 完全一样工作,
因为 Python 解释器启动时通过相对路径找 pyvenv.cfg,不靠环境变量。 -
VIRTUAL_ENV 是给工具看的,不是给 Python 看的:Python 解释器不依赖 VIRTUAL_ENV 环境变量,
靠的是文件系统上实实在在的 pyvenv.cfg。VIRTUAL_ENV 是给 IDE、shell 插件、自定义脚本等外部工具判断当前是否在 venv 里用的。
Python 包安装路径与隔离
-
site-packages 的 site 指本机不是网站:site 来自 Python 的 site 模块(启动时自动运行),
把 site-packages 加到搜索路径。命名逻辑:标准库在 lib/python3.14/ 下,随 Python 一起发布所有机器都一样;
site-packages 在 lib/python3.14/site-packages/ 下,
是本机(this installation site)额外安装的第三方包,每台机器不同。 -
pip install 默认全局安装,venv 通过切换搜索路径实现隔离:
直接 pip install 装到当前 Python 解释器的 site-packages,所有项目可见。venv 激活后,
解释器检测到 pyvenv.cfg 自动把 .venv 的 site-packages 替换掉全局路径,pip 就装到隔离目录里。
所以隔离的本质是切换 site-packages 搜索路径,而非复制 Python。 -
pipx 给每个 CLI 工具独立 venv:pipx install 在 ~/.local/pipx/venvs/ 下为每个包建独立 venv,
只把命令符号链接到 ~/.local/bin/。卸载完全干净,无依赖残留。比 pip install 全局安装安全,因为不同工具的依赖不会冲突。
pipx 自身用 brew 装是因为鸡生蛋问题——pipx 本身也是 Python CLI 工具,需要系统级安装通道。
Python 版本与依赖隔离分层
- venv 只能隔离包,不能隔离 Python 版本:venv 创建时记录 pyvenv.cfg 中的 home 指向真实 Python,
运行时只是切换 site-packages 搜索路径,解释器二进制本身没变。Python 3.14 创建的 venv 永远是 3.14。
版本隔离需要 mise(按项目切换 Python 版本,靠 PATH 和 shim)、pyenv、conda 或 uv 内置的 Python 版本管理。
mise 管版本 + venv 管包是两层独立隔离。
brew libexec 的三种使用场景
-
脉络:libexec 统一理解为包的内部实现细节,但装什么取决于运行时:从 FHS 原意到 brew 的实际用法,libexec 的核心语义不变——
"不直接暴露给用户的内部实现"。但具体内容随包的运行时需求变化:
Python 包放 venv、Java/脚本应用放完整应用目录、C 工具放辅助二进制、编译型单二进制直接不需要 libexec。四种情况构成完整认知框架。 -
Python venv 隔离场景:
brew 安装 Python 包(pipx、ansible 等)时不给全局 Python 的 site-packages 塞东西,
而是给每个包在 libexec/ 里建独立 venv,只暴露符号链接到 bin/。这和 pipx 自己做的事情一模一样——
brew 在包管理器层面复用了 venv 隔离模式。 -
应用根目录场景(thin wrapper):maven、mole 这类 Java/脚本应用,
libexec 是完整的应用目录(bin/boot/lib 等),bin/ 里只是薄包装脚本——负责设置环境变量后 exec 到 libexec 里的真实入口。
这和 git 的 libexec/git-core/ 模式一致:用户只看到 git 一个命令,
但 git push 实际执行的是 libexec/git-core/git-push。 -
内部辅助二进制场景(FHS 原意):
gettext 的 libexec 里放的是不被用户直接调用的辅助二进制(cldr-plurals、hostname、urlget 等)。
这是 FHS 最原始的 libexec 定义:程序内部使用的可执行文件,不作为公共接口。 -
编译型单二进制不需要 libexec:uv、git、ripgrep 等 Rust/Go/C 编译的单一二进制包没有 libexec,
因为没有依赖隔离需求,一个二进制文件就搞定。
工具安装策略——brew vs pipx vs pip
-
脉络:选择安装方式的核心判断标准是工具是否为 Python 包:编译型二进制用 brew 直接装,Python CLI 工具用 pipx 隔离依赖,
pipx 自身应该用 mise 的 Python 而非 brew 的。macOS 系统 Python 不应被用于任何 pip install 操作。 -
编译型工具用 brew,python 包用 pipx:uv 是 Rust 编译的二进制,不依赖 Python 运行时,
brew 直接下载二进制到 PATH 即可,pipx 装它只会多一层无意义的 venv 包装。
poetry、black、ansible 等依赖 Python 库的工具才需要 pipx,给每个建独立 venv 防止依赖冲突。 -
pipx 应该用 mise 的 Python 装,而非 brew 的:brew install pipx 会绑定 brew 的 Python,
如果已用 mise 管理 Python 版本,应该用 pip install pipx 让 pipx 基于 mise 的 Python,
避免同时维护两个 Python 运行时。之后 pipx install 创建的所有 venv 都会基于 mise 的 Python。 -
macOS 系统 Python 3.9 不应使用,mise Python 3.14 是最优解:
macOS 自带的 Python 3.9.6 是系统私有财产,给系统工具用的,不建议 pip install。且版本已旧(3.9 于 2021 年发布,
EOL 在 2025 年 10 月,很多新库已不支持)。mise 管理的 Python 3.14.6 既管版本又隔离系统,日常开发靠 venv 进一步隔离依赖,
是最优方案。
Durable Objects 并发与一致性模型
-
Actor 收口:每个 DO ID 对应一个全局唯一的活动实例,
这个实例在自己的单线程 JavaScript isolate 中处理 incoming HTTP/RPC/WebSocket 事件。
这不是让所有请求永远排成一条绝对队列,而是把同一业务实体的共享内存和持久化状态收拢到一个 Actor,
避免多个 Worker/机器同时直接读写同一份状态。 -
Input Gate:Input Gate 保护的是围绕 DO Storage 的自然 read-modify-write。
当 DO storage 操作正在等待完成时,
除了这个 storage 操作的 completion 事件之外,新的输入事件会被延后投递。
因此await storage.get()之后到await storage.put()之前,不会被另一个请求插入同一段基于 storage 的读改写流程。
但这不等于"任意 await 都天然互斥":如果 await 的是外部 fetch、timer 或手动并发启动的本地异步函数,仍需要自己判断是否会造成状态交错。 -
Output Gate:Output Gate 保证外部不会先观察到 premature confirmation。
当 storage write operation 正在进行时,DO 的响应或新的出站网络消息会被延后,
完成后才放行响应或新的出站网络消息;如果写入失败,这些出站消息会被错误替代,DO 会从头重启。
这意味着自然写法下,即使代码没有显式 await 某个 storage write,外部也不会先观察到"成功响应已经发出、持久化却还没落盘"的提前确认。
好处是可以在写入进行的同时做其他工作(如准备广播),只在出站边界由 Output Gate 兜底。 -
E-order 顺序:对同一 DO 的多次 RPC 调用,同一个 stub 发出的调用会按发起顺序到达 DO 实例。
这个顺序语义由 Cloudflare Workers 内部使用的 Cap'n Proto RPC 实现。
结合 DO 的 Actor 模型和 Input/Output Gates,形成 "同一 stub 的发送顺序可预期 + storage 读写边界受保护" 的行为。
注意:不同 stub 之间没有顺序保证;stub 一旦抛出异常,所有进行中和未来的调用都会失败,需要重建 stub。
DO 的 API 设计理念
-
API 三层:DO 的 API 分为三层——
命名空间定位(idFromName / newUniqueId)、获取代理(get)、RPC 调用。理解这三层的关系是消除 "API 不直接" 感觉的关键。
旧版 DO 需要手动 fetch 路由,新版(compat date >= 2024-04-03)直接通过 stub 调 class method,
与本地方法调用几乎一致。 -
ID 两类:idFromName 将字符串确定性映射为 DO ID,同一名字全局唯一,
适合按名称查找的场景(聊天室、用户会话)。newUniqueId 每次生成随机 ID 且跳过全局去重检查(首次调用更快),但必须自己存储 ID 才能找回实例,
适合临时资源(游戏对局、一次性任务)。 -
fetch 边界:DO 的 fetch() 方法名固定不可改,
因为它接收和返回的是 HTTP 标准对象(Request/Response),能处理协议级的事情(WebSocket 升级头、101 状态码)。
WebSocket 升级必须走 HTTP 协议,所以不能用普通 RPC 方法替代。RPC 方法适合业务级调用,能传 Workers RPC 支持的可序列化类型,
但它不是浏览器发起 WebSocket 握手时需要的 HTTP upgrade 通道。最佳实践:WebSocket 升级用 fetch,其他业务操作优先走 RPC。
WebSocket + DO 的底层连接模型
-
连接脉络:WebSocket + DO 的关键不是"浏览器直接连到 DO",而是浏览器、Cloudflare 运行时、DO 三者协作。
浏览器建立的是到 Cloudflare 网络的 WebSocket TCP 连接;Worker/运行时再把这个连接对应的事件路由到目标 DO 实例。
WebSocketPair创建的 client/server 两端不是两条真实 TCP 连接,而是运行时中的一对 WebSocket 端点:
client 端通过 HTTP 101 响应交还给运行时,server 端被 DO 接管。
Hibernation 能工作正是因为连接生命周期由 Cloudflare 运行时托管,而不是完全绑定在 DO 的内存对象上:
DO 内存可被回收,连接仍保持健康;下一条消息到达时,运行时重新构造 DO 并投递 WebSocket 事件。 -
升级两层:WebSocket 升级分两层完成。第一层是浏览器到 Cloudflare 的标准 HTTP Upgrade:
浏览器发Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key等头,服务端返回101 Switching Protocols后,
这条 HTTP 连接切换成 WebSocket 帧协议。第二层是 Worker/DO 内部接管:Worker 通过 stub 把 upgrade request 转给 DO 的fetch(),
DO 创建new WebSocketPair(),把 server 端acceptWebSocket(),再把 client 端放入new Response(null, { status: 101, webSocket: client })。
运行时看到这个特殊响应后,将浏览器那条已升级连接和 client 端桥接起来。 -
Pair 不是连接:
new WebSocketPair() 创建的两个标准 WebSocket 对象在 Cloudflare 运行时内部通过管道互联,
调用一端 send() 另一端就触发 message 事件。这不是 TCP 连接,而是运行时内存中的虚拟通道。
server 端通过 acceptWebSocket 注册到 DO;
client 端通过 Response 的 webSocket 字段(Cloudflare 专有扩展)交给运行时,
运行时将其与浏览器的 WebSocket 连接桥接。 -
client 交出:client 对象看起来"没被使用",
实际被塞进了 new Response(null, { status: 101, webSocket: client }) 中。
Cloudflare 运行时提取这个字段,把 client 端和浏览器的 WebSocket 连接桥接起来。
此后浏览器发来的 WebSocket 帧自动转发到 client,client 的 send() 自动转发到浏览器。
这是 Cloudflare 对 Response 构造函数的专有扩展,不是 Web 标准 API。 -
休眠恢复:Hibernation 下 DO 休眠时内存状态丢失,
但每个 WebSocket 连接可携带 "附件"(serializeAttachment),存在 Cloudflare 运行时中而非 DO 内存中。
唤醒后 constructor 中通过 getWebSockets() 遍历所有连接,deserializeAttachment() 恢复每个连接的状态。
这些 attachment 跟 WebSocket 连接生命周期绑定,连接关闭后也会丢失;它适合存 userId、roomId、joinedAt 等轻量元数据,
不适合替代 DO storage。ping/pong 心跳可通过 setWebSocketAutoResponse 配置自动回复,避免每次心跳都唤醒 DO。 -
恢复边界:连接路由与实例恢复不要写成具体内部锁流程。可以把位置缓存、租约、分布式锁理解成可能的实现模型,
但公开文档承诺的是 DO ID 到全局唯一活动实例的语义,以及故障/重启/迁移后新请求会被路由到新的实例。
不应把 "Flock 是真相来源"、"每次先查分布式锁"、"失败后按某个固定 T0/T1/T2 流程重解析" 写成已验证事实。
Durable Objects WebSocket
-
语义分层:理解 DO + WebSocket 时要区分公开语义、合理运行时模型和内部实现猜测。
公开语义包括全局唯一活动实例、Storage gates、E-order、WebSocket Hibernation;
连接节点和 DO 节点之间如何用锁、租约或路由表维护关系,不能写成 Cloudflare 已承诺的事实。 -
Actor 收口:DO 解决的不是让所有异步代码绝对串行,而是把同一业务实体的共享状态收拢到一个全局唯一 Actor 中。
Input Gate 保护围绕 DO Storage 的 read-modify-write,Output Gate 避免外部先观察到未落盘的成功响应;
前提是逻辑围绕 DO storage 的自然写法,不代表任意 await 都自动互斥。 -
连接解耦:WebSocketPair 的核心价值是把浏览器真实 TCP/WebSocket 连接和 DO 的计算实例生命周期解耦。
浏览器连接终止在 Cloudflare network/runtime,DO 持有 server endpoint 处理业务事件;
client endpoint 通过 101 Response 交还运行时桥接,因此 DO 可以休眠或重启而不必把连接完全绑死在内存对象上。 -
升级两层:WebSocket 升级先是浏览器到 Cloudflare 的标准 HTTP Upgrade,
返回 101 后连接切换成 WebSocket 帧协议;
然后 Worker/DO 用 WebSocketPair 把 client 端交给运行时、server 端 acceptWebSocket 给 DO。
这个过程说明 fetch 是协议边界,普通 RPC 方法不能替代 WebSocket 握手。 -
恢复边界:Hibernation、DO host 故障、TCP 连接节点故障是三种不同问题。
Hibernation 下客户端仍连到 Cloudflare network,
DO 内存清空后可由 getWebSockets 和 deserializeAttachment 恢复轻量连接状态;但真实 TCP 所在节点故障通常会断连接,
需要客户端重连,不能笼统说运行时无感恢复所有 WebSocket。
AG-UI 协议命名
-
AG 指交互边界:AG-UI 官方全称是 Agent-User Interaction Protocol,名字里的 UI 不是组件库意义上的界面,
而是 agentic backend 与 user-facing application 之间的事件交互边界。理解这个前提后,AG-UI 应放在协议谱系里看:
MCP 更偏 Agent 到工具和数据,A2A 更偏 Agent 到 Agent,而 AG-UI 关注 Agent 到用户界面的状态、意图和流式事件。 -
怪名来自定位:AG-UI 看起来不像直观产品名,是因为它优先表达协议定位而不是品牌可读性。若按普通前端习惯期待 Agent UI 组件库,
名字会显得别扭;但若按协议命名习惯理解,AG 更接近 Agent 或 Agentic 的缩写,UI 则指用户交互层的标准化接口。
Cloudflare Containers 与 DO
-
配置驱动实例化:Cloudflare Containers 里的 Container 子类不是由业务代码手动 new,
而是通过 wrangler 配置中的 class_name 绑定到 Durable Object namespace。代码中看不到直接引用时,
应先检查部署配置和 binding;前提是该类被导出,
并且 wrangler 的 containers、durable_objects、migrations 配置都指向同一个 class。 -
DO 负责定位:在 Containers 模型里,Durable Object 的核心作用是按名字定位一个稳定实例;
getContainer(env.CONTAINER_SANDBOX, sessionId) 本质上借用了 DO namespace binding 和 sessionId 来找到或创建对应实例。
前提是同一个 sessionId 代表同一个逻辑会话,因此后续请求会路由到同一个容器实例。 -
容器类管生命周期:Container 子类定义的是容器实例的运行参数和生命周期策略,例如 defaultPort 决定请求转发到容器内哪个端口,
sleepAfter 决定空闲多久后休眠。它不是普通请求处理器,而是 Cloudflare runtime 用来管理真实容器进程的控制器。
MCP 客户端 Feature 体系
-
客户端三大 Feature 是 Roots、Sampling、Elicitation:MCP 规范中客户端可提供给服务器的能力只有这三个。
Roots 声明可访问的文件目录边界(file:// URI 列表);Sampling 让服务器反过来请求客户端帮忙调一次 LLM(服务器没有自己的 LLM,
需要从客户端"抽");Elicitation 让服务器在执行中向用户提问以获取缺失信息。
这与服务器端三大 Feature(Resources/Prompts/Tools)形成对称——服务器说"我能干什么",客户端说"你需要什么资源或帮助"。 -
Sampling 命名源于统计学"抽样":不是"反向调用 LLM"或"LLMRequest"这样的直白名称,
而是取"从客户端抽取一次 LLM 调用"的语义。服务器自己不具备 LLM 能力,需要从宿主客户端那里"抽"一次模型推理。这个命名对非协议实现者不直观,
但对协议设计者表达了"客户端拥有 LLM,服务器只能采样"的架构意图。 -
Elicitation 命名源于心理学"诱导/引出":不是简单的"问用户问题"(Q&A),
而是特指通过结构化提问从用户那里"引出"原本不会主动提供的信息。这个词在心理学审讯、教育引导等场景中使用,强调的不是"问"的动作,而是"引导出答案"的效果。
在 MCP 语境下,服务器通过 elicitation 向宿主客户端发起信息请求,客户端的用户提供回答后传回。 -
脉络:理解 MCP 设计哲学——Host、Client、Server 三层角色:MCP 协议定义了 Host(LLM 应用,
如 Claude Code)、Client(应用内的连接器层)、Server(提供上下文和能力的服务)三层。客户端 Feature 之所以存在,
是因为 Server 在某些场景下处于"被动"角色——它需要文件边界、需要 LLM 能力、需要用户输入,但自己没有,只能通过协议从 Client 侧获取。
这解释了为什么 Sampling 和 Elicitation 都是"反向请求"模式(Server → Client),
与日常使用的 Tools/Resources 的"正向调用"模式(Client → Server)正好相反。
SSE 协议
-
SSE 是 HTTP 长连接单向推送:SSE 基于 HTML 标准定义,服务端通过 HTTP 长连接向客户端单向推送事件流。
与 WebSocket 双向不同,SSE 只支持服务端→客户端。JSON-RPC 和 MCP 等协议常把 SSE 作为传输层,客户端请求走 HTTP POST,
服务端响应走 SSE 流。 -
空行是事件分发触发器:SSE 流中每个事件由空行(\n\n)分隔。客户端解析时逐行读取 field:value,遇到空行才触发事件分发。
这意味着事件边界由空行控制,而非由 data 字段的内容决定。 -
data 多行自动拼接:多条 data: 行会被拼接成一个字符串,行间插入 \n。末尾多余的 \n 在分发前会被去掉。
此外 data 字段有三种边界情况:只有字段名无冒号时值为空字符串、多行无值 data 拼接结果为 \n、只有冒号无值时不触发事件(因为没有空行结束)。 -
event 字段控制前端监听方式:不写 event 字段时默认触发 message 事件(用 onmessage 监听),
写了 event 名后前端需用 addEventListener 监听对应事件类型。id 字段用于断线重连——
浏览器自动在重连请求中带 Last-Event-ID 头,服务端可据此续推。retry 字段控制重连间隔(毫秒)。 -
冒号开头的行是注释:以 : 开头的行被 SSE 解析器忽略,不参与事件构建。常用于发送心跳保活——因为 HTTP 长连接可能被中间代理超时断开,
定期发注释行可维持连接。 -
SSE + JSON-RPC 构成 MCP Streamable HTTP:
SSE 单向推送 JSON-RPC 的 Response 和 Notification,每条 JSON-RPC 消息包在一个 data: 行里。
客户端通过 HTTP POST 发 Request,服务端通过 SSE 流回 Response。
MCP 的 Streamable HTTP 传输就是这种组合模式。
LSP 协议核心机制
-
脉络:LSP 不是"跑在 HTTP 上的 API",而是基于 JSON-RPC 2.0 的本地 IPC 双工协议。这一设计选择由场景决定——
编辑器和 Language Server 通常在同一台机器上,HTTP 的请求-响应模式也不适合服务端主动推送诊断等场景。
能力协商机制则进一步体现了 LSP 的"可选性哲学"——双方通过 initialize 交换能力清单,后续只触发共有的功能,避免无效请求。 -
LSP 传输层是 JSON-RPC over stdio 而非 HTTP:LSP 使用 JSON-RPC 2.0 协议,
默认通过标准输入输出、管道或 socket 进行本地进程间通信。
选择 stdio 而非 HTTP 的原因是编辑器和 Language Server 通常在同一台机器上运行,stdio 延迟更低、无额外开销,
且天然支持双工通信(服务端可以主动推送如 textDocument/publishDiagnostics 等通知),
而 HTTP 的请求-响应模型不适合这种场景。 -
LSP 能力协商发生在 initialize 阶段:客户端和服务端通过 initialize 请求/响应交换能力清单。
客户端在 initialize 请求中声明 ClientCapabilities(如是否支持 completion、hover、codeAction 等),
服务端在 InitializeResult 中声明 ServerCapabilities(如 completionProvider、definitionProvider 等)。
在服务端返回 InitializeResult 之前,客户端不能发送任何其他请求。大部分能力都是可选的,
只有 textDocument/didOpen、didChange、didClose 三个通知是强制性的。
客户端通过 dynamicRegistration: true 还可告知服务端支持运行时动态注册/注销能力,不必在 initialize 时定死所有能力。 -
脉络:LSP 能力协商的本质是"分布式隐式求交"而非"集中计算交集"。客户端和服务器各自独立检查对方的声明——客户端只发服务器声明了的能力请求,
服务器只推客户端声明了的能力通知。不存在一个中央逻辑做 Client ∩ Server 运算。这个设计遵循协议的基本原则:"声明即契约,不声明就不可用"。 -
能力交集通过分布式遵守达成:LSP 没有显式的"求交集"步骤。客户端拿到 ServerCapabilities 后,
若服务端没声明 definitionProvider,客户端就不会发 textDocument/definition 请求;
服务端拿到 ClientCapabilities 后,若客户端没声明 codeAction,服务端就不会推送 codeAction 相关通知。
交集是双方各自遵守对方声明来自然形成的,而非某个集中计算的结果。dynamicRegistration 机制进一步让"交集"可以动态变化——
服务端在 initialize 后通过 client/registerCapability 注册新能力或 unregisterCapability 注销已有能力,
打破静态协商的局限。
LSP 与 LSIF 关系
- LSIF 是 LSP 的离线索引格式:LSP 解决的是实时 IDE 交互场景(需要本地源码 + 运行中的 Language Server 进程),
而 LSIF(Language Server Index Format)解决的是代码托管平台 Web 浏览场景(如 GitHub 上点"跳转到定义")。
LSIF 的思路是让 Language Server 在 CI 中提前把分析结果导出为有向图文件(vertices + edges),查询时直接遍历图即可,
不需要跑 Language Server 也不需要本地源码。
LSIF 的 edge label 严格对应 LSP 的请求类型(如 textDocument/definition、textDocument/hover),
数据模型对齐,从 LSP 到 LSIF 的转换不需要复杂映射。
Git 重命名检测与展示
-
Git 不跟踪重命名,事后检测相似度:git 内部不记录文件重命名操作。git status 发现一个文件消失、另一个出现后,用相似度算法比对内容;
超过默认阈值 50% 就标记为 rename。(100%) 表示内容完全一致,所以 git 确信是重命名。这意味着 mv 就是普通的 mv,
rename 是 git 事后"猜"出来的,不是记录的元数据。 -
{旧 => 新} 是 git 路径压缩展示:这是 git status/git diff 人类可读模式下的渲染简写,
不是 bash 的 brace expansion。git 找出新旧路径的公共前缀和后缀,只把差异部分用 {} 包起来,前面旧名、后面新名,
避免把两段几乎相同的长路径完整显示两遍。本质上是个 diff 友好缩写,与 bash 的 {a,b} 展开语法完全无关。
Agent Client Protocol stdio 传输分帧
-
脉络:从提问到本质认识:对话从"ACP 基于 IPC(stdio) 如何拆包"开始,
先澄清了"ACP"缩写对应两个不同协议(Agent Communication Protocol vs Agent Client Protocol),
然后聚焦到真正用 stdio 的 Agent Client Protocol,逐步拆解其分帧机制、JSON 转义处理,
最终归纳为"本质上就是 JSONL over stdio",并与 MCP 的 Content-Length 方式做了对比。 -
两个 ACP 协议不可混淆:Agent Communication Protocol(agentcommunicationprotocol.dev,
IBM/BeeAI)是 Agent 间通信的 REST 协议,已并入 A2A;
Agent Client Protocol(agentclientprotocol.com,
Zed Industries)是编辑器与 AI Coding Agent 间的 JSON-RPC 协议,基于 stdio。两者域名、定位、传输层完全不同,
讨论时需要先确认指的是哪个。 -
stdio 分帧 = 换行分隔 JSONL:Agent Client Protocol 的 stdio 传输使用换行符
\n作为消息分隔符,
每条 JSON-RPC 消息序列化为一行,不得包含嵌入换行。读取端按行切割即可得到完整消息,
本质上就是 JSONL (JSON Lines) over stdio 双向流。 -
JSON 转义天然解决换行冲突:当消息体内容包含换行时,JSON 序列化器会将实际换行字节转义为
\n(两个字符:反斜杠 + n),
而非字面0x0A字节。因此消息体在字节层面仍是一行,只有末尾的\n是真分隔符。这要求 JSON 序列化时不能 pretty-print,
必须用紧凑格式。 -
对比 MCP:Content-Length vs 换行分隔:
MCP 使用Content-Length: N\r\n\r\n头部 + 正文的分帧方式,允许消息体包含任意字节和 pretty-print;
Agent Client Protocol 选择换行分隔,约束更简单但要求消息体必须是紧凑 JSON。两者代表了两种帧定界策略的权衡:
MCP 追求灵活性(代价是解析复杂度),ACP 追求简单性(代价是消息体约束)。
Agent Client Protocol Session 机制
-
脉络:从 Session 概念到 Agent 回放设计:对话从"Session 是什么"开始,
先澄清 Session 是独立的对话上下文(历史、状态、cwd、MCP 连接),然后追问为什么 load 时要 Agent 回放全部历史给 Client。
核心在于所有权模型——Agent 是对话状态的唯一权威源,Client 只是无状态渲染层。回放设计的两个关键收益:保证 Client 崩溃后能拿到完整历史,
且复用实时 streaming 的同一条渲染路径。 -
Agent 是对话状态的唯一权威源:ACP 中 Agent 负责持久化对话历史,Client(编辑器)只是渲染层。Client 崩溃或断开后,
Agent 可能已离线产生新消息,Client 根本没见过。load 时由 Agent 全量回放,保证 Client 拿到的是 Agent 视角的完整版本,
而非 Client 本地可能不完整的部分。 -
load 与 resume 的两种重连策略:
session/load回放完整历史,适合 Client 本地无状态或需要重建视图的场景;
session/resume只恢复上下文和 MCP 连接、不回放历史,是轻量重连,适合 Client 自己记着历史的场景。
两种策略共存说明 ACP 既保底(load)也优化(resume),不强制一种路径。 -
Replay 复用实时渲染路径:load 的时间放形式是重放
session/update通知流——和实时对话完全相同的消息格式。
Client 不需要两套代码("实时渲染" + "从存储加载历史"),同一套session/update处理器即可。
这类似于 LSP 的publishDiagnostics在重连后重放的模式。
ACP Proxy 工具授权桥接机制
-
脉络:从协议规范到源码实现:
先从 ACP 协议层了解了session/request_permission四种权限选项(allow/reject × once/always),
再看了 Claude Code 内置的七层权限决策管道,最后聚焦到 ACP Proxy(claude-agent-acp)的源码——
它是如何把 SDK 的 canUseTool 回调翻译成 ACP 权限请求的。三段递进:协议定义 → 独立实现 → 桥接适配。 -
canUseTool 是唯一集成点:ACP Proxy 不做任何权限决策,
它只是把 SDK 的 canUseTool 回调翻译成 ACP 的 session/request_permission。
SDK 想执行工具时先调用 canUseTool,Proxy 据此构建 PermissionOption[] 发给编辑器,
等用户选择后翻译回 PermissionResult。整个 Proxy 的权限逻辑就是这一个函数。 -
三种特殊工具有独立处理路径:
AskUserQuestion不走权限对话框,而是转为 ACP form elicitation(表单问卷),
要求 Client 支持 elicitation.form 能力;ExitPlanMode转为模式切换权限请求,
选项是 auto/acceptEdits/default/plan 等模式而非 allow/reject;
bypassPermissions模式下直接放行,不经过 Client。
其余所有工具走标准三选项(Allow Always / Allow / Reject)。 -
permission_denied 消息处理自动拒绝:SDK 侧的规则匹配、分类器、dontAsk 模式可能在 canUseTool 之外自动拒绝工具。
此时 SDK 发出 permission_denied 系统消息,Proxy 的 consumer 循环收到后标记对应的 tool_call 为 failed,
确保编辑器不会看到一个永远不 resolve 的 tool call。
Claude Code Agent SDK 跨进程架构
-
SDK 是 TS 库,CLI 是独立子进程:
@anthropic-ai/claude-agent-sdk是 TypeScript 库,
跑在调用方的 Node.js 进程里。它 spawn Claude Code CLI 原生二进制作为子进程,两者通过 stdio 上的控制协议通信。
SDK 不自己跑推理,只负责子进程生命周期管理和协议翻译。 -
canUseTool 回调不需要跨进程序列化:canUseTool 是 JS 函数引用,但它从未离开 Node.js 进程。实际流程是反向的:
CLI 子进程通过 stdio 发送"我要执行工具 X,请批准"消息,SDK 在同进程内调用 canUseTool 获取结果,
再通过 stdio 把结果传回 CLI。函数始终在进程内,跨进程的只是权限请求/响应的结构化消息。 -
query() 返回 AsyncGenerator + 控制方法:
query()返回 Query 对象,
既是 AsyncGenerator(可 for await 消费消息流),又提供运行时控制方法——
interrupt()、setModel()、setPermissionMode()、streamInput()等。
这允许在流式消费过程中动态调整模型、权限模式、甚至注入新消息,而无需重启 CLI 子进程。
Claude Code SDK 控制协议
-
脉络:从 canUseTool 集成方式到协议细节:
先追问了 SDK 的 canUseTool 回调如何跨进程调用(Claude Code 是独立进程),
发现 SDK 是 TS 库与 CLI 二进制同进程的包装器,CLI 通过 隐藏参数把权限请求发送到 stdout,
SDK 在进程内调用回调后把结果写回 stdin。
接着深入源码验证了 control_request/control_response 的具体消息格式和协议设计。 -
SDK 用 stream-json 而非 claude -p 做单次问答:SDK 不是 spawn Dewu(Deepseek) Claude Code...
[3J[H[2JNot logged in · Please run /login 做单次调用,而是 spawn 时传 ,建立持久化的双向 NDJSON 流。这支持多轮对话、工具调用、权限请求等完整 agentic 行为,CLI 进程在整个 session 期间保持存活。 -
canUseTool 回调的跨进程本质是反向请求:CLI 是独立子进程,canUseTool 是 JS 函数,两者无法直接跨进程调用。实际机制是反向的:
CLI 通过 stdout 发送 control_request 请求审批,SDK 在同进程内调用 canUseTool 回调,
把返回值通过 stdin 的 control_response 传回 CLI。函数从未离开进程,跨进程的只是结构化消息。 -
control_request/control_response 协议格式:
权限请求时 CLI 向 stdout 发{"type":"control_request","request_id":"...","request":{"subtype":"can_use_tool","tool_name":"...","input":{...},"tool_use_id":"..."}}。
SDK 消费方向 stdin 回{"type":"control_response","response":{"subtype":"success","request_id":"...","response":{"behavior":"allow"|"deny",...}}}。
取消用control_cancel_request。消息按 request_id 匹配,
CLI 端用 pendingRequests Map 追踪 Promise。 -
本地优先 + Hook 竞速的双层决策:
createCanUseTool 先跑 hasPermissionsToUseTool(settings.json 规则、安全检查等),
如果已有明确结论就不发送 control_request,直接返回。无结论时才发 control_request,
同时后台跑 PermissionRequest hooks,两者竞速——谁先返回就用谁的决策,输的一方被取消。
这保证了本地规则和 hook 的优先级高于远程 SDK 消费方。