跳转至

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: websocketConnection: UpgradeSec-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...
    Not 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 消费方。