双架构演进与NDJSON远程协议——Agent突破本地边界

双架构演进与NDJSON远程协议——Agent突破本地边界
双架构演进与NDJSON远程协议——Agent突破本地边界《Claude Code 架构解密》精读笔记 · 第18篇 · 第13章核心洞察Agent远程化的关键不是如何传输而是状态归谁所有——这个决定将导出通信架构、安全模型和故障语义的全部设计。导语当 Agent 必须离开终端前十二章讨论的所有机制——查询循环、工具执行、权限审批、上下文压缩——都假设用户坐在终端前与本地CLI实时交互。但现实世界的场景远比这复杂 通勤时用手机查看Agent是否完成了耗时重构任务 在浏览器中直接向本地机器发起新的编码请求 同时管理多个并行的编码会话这带来了一个根本性的架构挑战如何将一个为本地设计的Agent系统透明地延伸到远程端同时不降级安全保证、不丢失消息一致性Claude Code 的回答是Bridge/Remote 系统——一套语义级会话桥接架构。本章将揭示为什么不用SSHv1/v2双架构如何演进三层消息去重解决了什么问题权限如何跨端桥接Token生命周期如何管理一、三条路径的抉择为什么不用SSH1.1 远程交互的三种经典方案面对远程控制本地Agent的需求架构师有三种经典选择方案传输内容代表产品延迟敏感度安全模型终端流转发字符/ANSI转义序列SSH、tmux、Mosh高每次按键全权委托UI状态同步渲染后的UI组件树VNC、Chrome Remote Desktop高帧级全权委托语义消息桥接协议消息用户输入、工具审批Claude Code Bridge低消息级可选择性审批Claude Code 选择了第三条路——语义消息桥接背后有三个深层原因1.2 三个不可妥协的理由理由一权限模型不可降级。回顾第5章的权限系统Claude Code的核心安全承诺是每个危险操作都需要用户确认。终端转发方案如SSH将远程端视为完全信任的用户——按下回车就等同于本地执行整个权限系统被绕过。语义桥接则保留了消息级别的权限拦截点远程容器发出can_use_tool控制请求本地TUI弹出审批对话框审批结果通过control_response回传。理由二带宽效率。Agent对话的交互模式是突发式——用户发送一个请求Agent可能执行10分钟然后返回结果。实时字符流转发SSH/Mosh的大部分带宽浪费在Spinner动画和进度条这些无语义信息上。语义桥接只传输有意义的消息用户输入、工具调用、模型响应。理由三多端渲染自由。语义消息是结构化数据JSON而非终端字符流。Web端可以用React组件渲染消息移动端可以用原生UI渲染不必模拟终端。这种解耦使各客户端体验独立演进。1.3 Bridge/Remote的架构定位Bridge/Remote系统横跨编排层L3和交互层L4是一个横切层Claude Code 五层架构 ┌──────────────────────────────────────────────┐ │ L5 入口层: main.tsx / cli.tsx │ │ 本地路径 ↕ 远程路径 │ ├──────────────────────────────────────────────┤ │ L4 交互层: REPL.tsx ↔ useRemoteSession │ ├──────────────────────────────────────────────┤ │ L3 编排层: QueryEngine ↔ RemoteSessionManager│ ├──────────────────────────────────────────────┤ │ Bridge/Remote系统横切层 │ │ src/bridge/出站方向本地→云端 │ │ bridgeMain.ts — 独立桥接服务器 │ │ replBridge.ts — REPL桥接核心(v1) │ │ remoteBridgeCore.ts — Env-less桥接(v2) │ │ bridgeMessaging.ts — 消息路由与去重 │ │ sessionRunner.ts — 子进程孵化器 │ │ src/remote/入站方向云端→本地 │ │ RemoteSessionManager.ts — 远程会话管理 │ │ SessionsWebSocket.ts — ws客户端 │ │ sdkMessageAdapter.ts — 协议适配 │ │ remotePermissionBridge.ts — 权限桥接 │ ├──────────────────────────────────────────────┤ │ L1 基础设施层API / OAuth / 状态存储 │ └──────────────────────────────────────────────┘关键架构决策Bridge和Remote是两个方向相反的系统。Bridge是把本地会话暴露给云端出站Remote是在本地消费云端会话入站。它们共享消息协议和去重机制但职责完全独立。1.4 与主流远程方案的核心对比维度Claude Code BridgeVS Code RemoteGitHub CodespacesCursor Remote传输语义Agent协议消息语义级完整开发环境容器级LSP文件系统API级编辑器AI混合级计算位置本地执行远程观察远程执行本地渲染全远程远程推理编辑权限模型跨端审批隐式信任(SSH)容器沙箱编辑器权限离线能力断连不中断执行断连即停止断连即停止部分可用状态所有权本地拥有远程拥有远程拥有混合核心洞察状态所有权决定通信架构。表中最根本的差异在最后一行。VS Code Remote和Codespaces将状态放在远程本地只是渲染层。Claude Code恰恰相反——计算留在本地远程只是交互的另一个入口。这个决策的连锁效应是深远的断连语义不同远程拥有状态时断连工作停止本地拥有状态时断连远程端丢失视图但Agent继续执行一致性要求不同远程拥有状态时需要可靠队列保证每条消息到达本地拥有状态时尽力同步就够——丢几条消息不影响执行重连后可以重新flush完整历史安全模型不同远程拥有状态时远程端是完全信任的执行环境本地拥有状态时远程端只是受控的交互通道二、双架构并存v1与v2的演进2.1 从纯本地到分布式的三阶段Claude Code的远程能力经历了清晰的三阶段演进阶段1纯本地REPL初始架构用户 → REPL → QueryEngine → [工具执行 权限审批] 全部在同一进程内 问题用户必须守在终端前阶段2v1 Env-based BridgeDaemon模式CCR后端 ← bridgeMain持久进程→ 环境注册 → Web/App ↓ spawn 子进程CLI JWT管理 优势多会话、持久化、崩溃恢复 代价冷启动慢注册→轮询→spawn约5-10秒阶段3v2 Env-less Bridge快速连接Code Session → REPL进程同进程→ claude.ai → API/Web/App 无需spawn直接JWT 优势3次HTTP请求1次SSE建立2秒内完成 代价不支持多会话、不支持持久模式为什么没有直接跳到阶段3这是一个经典的先做对再做快的架构决策。v1的Daemon模式承担了关键的验证使命——它证明了远程控制的产品价值和安全模型。权限桥接、消息去重、崩溃恢复这些核心能力是否必需在v1中得到了回答。v2在此基础上做减法——保留已验证的核心能力去掉REPL场景不需要的复杂性。2.2 v1与v2的架构对比维度v1 Env-basedv2 Env-less入口文件replBridge.tsremoteBridgeCore.ts连接模型注册环境→长轮询→接受工作→建传输创建会话→换JWT→直接建传输传输协议WebSocket或SSESSE HTTP POST认证链OAuth→Environment Secret→Session JWTOAuth→worker_jwt多会话支持最多32个并发不支持持久模式支持(perpetual)不支持适用场景Daemon/多会话/claude remote-controlREPL快速连接代码规模~2,400行~1,000行功能开关tengu_ccr_bridgetengu_bridge_repl_v22.3 并存的代价三处代码分叉v1/v2并存的架构债务体现在三处代码分叉① 消息写入路径分叉replBridge.tswriteMessages()— 1,800行闭包中的62行实现remoteBridgeCore.tswriteMessages()— 独立的45行实现语义相同但去重策略细节不同② Token刷新策略分叉v1updateAccessToken→ 子进程环境变量热更新v2rebuildTransport→ 重建整个传输层③ 条件分支蔓延// initReplBridge.tsif(isEnvLessBridgeEnabled()!perpetual)// 选择 v1 还是 v2// 但 perpetual 的判断散落在多处⚠️设计模式渐进式架构演进的陷阱。“先做对再做快是正确的策略但如果v1的某些能力无法被v2覆盖你得到的不是渐进式演进”而是两套需要长期维护的代码路径。解法是在v1阶段就为未来简化预留抽象点——将多会话管理和连接建立明确分离为独立模块使v2可以替换后者而复用前者。2.4 v1连接建立流程initBridgeCore(params) ① 读取崩溃恢复指针bridge-pointer.json ② createBridgeApiClient(baseUrl, getAccessToken, ...) ③ api.registerBridgeEnvironment(config) → environmentId secret ④ [perpetual] tryReconnectInPlace(prior.envId, prior.sessionId) ⑤ createSession({environmentId, title, ...}) → sessionId ⑥ writeBridgePointer(dir, {sessionId, environmentId}) ⑦ startWorkPollLoop(pollOpts) ← 核心轮询循环 pollForWork() → work → decodeWorkSecret() → acknowledgeWork() useCcrV2? → createV2ReplTransport(SSE CCR) v1? → createV1ReplTransport(HybridTransport/WS) wireTransport → onConnect → flushHistory → ready ⑧ registerCleanup(teardown)步骤⑦的startWorkPollLoop是一个长轮询循环Bridge向CCR后端发送pollForWork请求后端挂起连接直到有新的远程工作到来或超时。这种服务端推送模式虽然增加了连接建立的延迟但避免了维护持久WebSocket连接的复杂性。2.5 v2连接建立流程v2的设计哲学是能省则省——去掉环境注册、长轮询和子进程initEnvLessBridgeCore(params) ① getEnvLessBridgeConfig() // GrowthBook 配置 ② withRetry(createCodeSession) // POST → session_id ③ withRetry(fetchRemoteCredentials) // POST → worker_jwt epoch ④ buildCCRv2SdkUrl(api_base_url, sessionId) ⑤ createV2ReplTransport(SSE CCRClient) ⑥ 初始化去重Sets FlushGate ⑦ createTokenRefreshScheduler(5min buffer) ⑧ wireTransportCallbacks() ⑨ transport.connect() // SSE CCR initialize ⑩ 返回 ReplBridgeHandle从调用到连接可用只需3次HTTP请求1次SSE建立冷启动时间从v1的5-10秒降低到2秒以内。代价是放弃了多会话和持久模式——典型的场景聚焦Trade-off。三、消息传输架构三层去重的精巧设计3.1 消息流向拓扑消息在两个方向上流动每个方向有独立的处理管线出站本地→云端 REPL消息更新(useEffect) → Bridge层过滤转换 → isEligibleBridgeMessage → toSDKMessages → UUID去重 → /worker/events (HTTP POST) 入站云端→本地 SSE/WebSocket事件流 → 控制请求路由 → handleIngressMessage → Echo过滤 → Re-delivery去重 → REPL渲染setMessages3.2 出站消息过滤不是所有本地消息都值得同步到远程端。bridgeMessaging.ts中的isEligibleBridgeMessage函数扮演了消息海关消息类型是否转发原因user✅用户输入是对话核心assistant✅AI响应需要远程可见system(subtype: local_command)✅斜杠命令结果需同步system(其他)❌内部系统消息无需远程展示虚拟消息(synthetic)❌本地渲染辅助非实际对话 这个过滤策略体现了语义桥接的核心优势——只传输有业务意义的消息大幅减少网络开销。大量的Spinner更新、进度条和虚拟分隔线都被过滤掉了。3.3 三层消息去重消息去重是Bridge系统最精巧的设计之一。为什么需要三层因为重复消息来自三个不同的来源每个来源需要不同的去重策略。第一层序列号续传Sequence NumberSSE传输支持from_sequence_num/Last-Event-IDTransport重建时继承lastTransportSequenceNum防止服务器重放完整历史旧Transportseq42—close—→ 新Transport connect(from_sequence_num42) → 服务器从seq43开始发送第二层BoundedUUIDSet环形缓冲区去重序列号解决了传输层重放但还有另一种重复你发出的消息被服务器Echo回来。BoundedUUIDSet用环形缓冲区Set组合解决classBoundedUUIDSet{privatering:(string|undefined)[]// 固定容量2000的环形数组privatesetnewSetstring()// O(1)查找privatewriteIdx0add(uuid:string):void{if(this.set.has(uuid))return// 驱逐最旧的UUID为新UUID腾出空间constevictedthis.ring[this.writeIdx]if(evicted)this.set.delete(evicted)this.ring[this.writeIdx]uuidthis.set.add(uuid)this.writeIdx(this.writeIdx1)%this.capacity}has(uuid:string):boolean{returnthis.set.has(uuid)}}这个数据结构解决了一个经典问题需要去重但不能让内存无限增长。普通Set会随时间无限膨胀LRU Cache又太重。环形数组Set的组合用O(capacity)的固定内存实现了O(1)的查找和插入最旧条目自动被驱逐。容量设为2000足以覆盖一个典型会话中的往返消息数量。系统实际维护了两个独立的BoundedUUIDSetrecentPostedUUIDs记录自己发出的消息UUID过滤Echo回来的副本recentInboundUUIDs记录收到的入站消息UUID防御传输切换时的重复投递v2还额外维护一个initialMessageUUIDs普通Set用于兜底初始flush阶段的UUID去重。第三层FlushGate门控前两层解决了单条消息的去重但还有一个更微妙的问题批量同步与增量同步的交错。FlushGate时序 时间 ─────────────────────────────────────────────→ 闸门关闭 闸门打开 [flush → drain →] 历史消息 新消息进入队列 排空队列 正常传输 [批量发送] [暂时缓冲] [按序发出] [实时]当Transport首次连接或重建时Bridge需要将历史消息批量flush到远程端。在flush进行期间新的实时消息可能已经开始到达。如果不加控制这些实时消息可能插入到历史消息中间导致时序错乱。FlushGate的解法是初始历史flush开始时闸门关闭——新消息进入等待队列flush完成后闸门打开——排空队列中的等待消息恢复正常实时传输Transport重建时也启动FlushGate防止旧epoch的消息被静默丢弃设计模式分层去重。当消息重复来自多个独立来源传输层重放、Echo回环、批量/增量交错时一层去重无法覆盖所有情况。正确的做法是先枚举所有可能的重复来源再为每个来源设计对应的去重策略。层与层之间互不依赖任何一层失效不影响其他层的防护。3.4 为什么不用消息队列方案优势劣势当前尽力同步低延迟、无持久化开销、代码简单Transport断开时消息丢弃替代本地消息队列消息不丢失、断线重传需持久化、需幂等性保证、复杂度高当前设计的合理性在于Bridge同步的是REPL会话的实时视图不是关键业务数据。即使丢失几条消息用户在Transport恢复后仍能通过重新flush历史消息获得完整视图。writeMessages函数在无transport时直接丢弃并记录警告明确接受了这个Trade-off。这是状态归本地所有的又一个推论——既然远程端只是视图那视图短暂不完整是可以接受的不值得为此引入持久化消息队列的复杂性。四、会话生命周期管理4.1 独立桥接模式bridgeMain.tsbridgeMain.ts是一个近3000行的微型会话调度器作为独立的Daemon进程运行管理多个远程会话的完整生命周期bridgeMain()启动流程 参数解析 → OAuth认证 → 信任检查 → 选择spawn模式: single-session一个会话结束即退出 same-dir多会话共享目录 worktree多会话各自git worktree隔离 → 注册环境 → POST /v1/environments/bridge → 预创建空会话 → createBridgeSession() → 进入runBridgeLoop(): pollForWork() → 长轮询获取工作项 decodeWorkSecret() → 提取JWT acknowledgeWork() → 确认接受 safeSpawn() → sessionRunner创建子进程 子进程: claude --print --sdk-url url --input-format stream-json --output-format stream-json --replay-user-messages stdout → NDJSON解析 → 活动状态提取 stdin → Token刷新(update_environment_variables) stderr → 环形缓冲区最近10行诊断spawn模式值得关注worktree模式为每个远程会话创建独立的git worktree实现了文件系统级别的隔离——多个远程用户可以同时在同一仓库的不同分支上工作互不干扰。这与第6章讨论的Agent编排中的fork-and-delegate模式异曲同工。4.2 容量管理与心跳模式当并发会话达到上限默认32受GrowthBook Feature Flag控制时Bridge不是简单地拒绝新连接而是切换到心跳模式容量满载时的行为切换 正常模式pollForWork() → 获取新工作 → spawn子进程 ↓ activeSessions.size maxSessions 心跳模式heartbeatActiveWorkItems() → 维持环境存活 → 某会话结束 → capacityWake.signal() 正常模式立即恢复pollForWork()为什么需要心跳如果Bridge停止轮询CCR后端会认为环境已死亡并回收它。心跳模式通过定期调用heartbeatWork()保持环境存活同时不接受新工作。容量唤醒机制capacityWake是另一个精巧的设计当任何会话结束时capacityWake.signal()会立即中断心跳模式的sleep让Bridge尽快回到pollForWork接受新工作。这避免了用户不必要的等待。4.3 崩溃恢复双策略恢复对于Daemon模式的Bridge进程崩溃是现实威胁。Bridge使用指针文件双策略恢复来应对崩溃恢复链路 写入指针: writeBridgePointer(dir, { sessionId, environmentId, source }) → 在会话创建后立即写入 → 每小时刷新mtime → 4小时TTL过期自动删除 读取指针: readBridgePointerAcrossWorktrees() → --continue参数触发 → 扫描当前目录及所有git worktree 恢复流程 → 策略1原地重连(reconnect-in-place) reuseEnvironmentId → 后端返回相同envId → reconnectSession() 优势快速复用旧会话UUID和历史消息 → 策略2新建会话(fresh session fallback) 后端返回不同envId → archiveSession(旧) → createSession(新) 触发条件环境已被后端GC4小时后双策略恢复体现了一个通用模式乐观尝试悲观兜底。先尝试最快的恢复路径原地重连如果失败则自动fallback到更安全但更慢的路径新建会话。4.4 优雅关机Bridge的关机不是简单的process.exit()而是一个有序的teardown序列teardown()流程非perpetual模式 ① transport.write(makeResultMessage) ← 通知服务端会话结束 ② api.stopWork(forcetrue) ← 释放工作项 ③ archiveSession(currentSessionId) ← 归档会话 ④ transport.close() ← 关闭传输 ⑤ api.deregisterEnvironment(envId) ← 注销环境 ⑥ clearBridgePointer(dir) ← 清理指针文件持久模式perpetual的差异值得注意——“选择性不清理”不发送result → 服务端不归档会话会话保持活跃不注销环境 → 环境保持存活依赖300秒leaseTTL自动回收不清理指针 → 下次--continue可以恢复这种设计让持久模式的Bridge可以在重启后快速恢复上一个会话。代价是如果Bridge进程意外消失环境会在300秒后被TTL自动回收——合理的超时窗口。五、Remote系统消费端架构5.1 四种远程模式模式实现用例–remote(CCR云端会话)RemoteSessionManager → SessionsWebSocket HTTP POST连接到远程运行的Claude Code会话claude assistant(CCR viewerOnly)同上但禁用interrupt/标题更新/超时检测只读观察远程会话claude connect(Direct Connect)DirectConnectManager → 类似API连接本地服务器IDE集成/本地多客户端claudessh(SSH子进程)SSH tunnel → stdin/stdout传输通过SSH隧道连接远程机器REPL通过统一抽象选择当前生效的远程后端constactiveRemotesshRemote.isRemoteMode?sshRemote:directConnect.isRemoteMode?directConnect:remoteSession四种模式在回调形状onMessage、sendInput、onPermissionRequest等上保持一致使REPL不需要关心消息实际来自哪种远程后端——依赖注入模式的又一个应用。5.2 WebSocket断线重连策略不同的关闭原因对应不同的重连行为关闭码含义重连策略4003Unauthorized永久关闭不重连4001Session not found独立重试最多3次递增延迟其他网络/服务器错误仅在之前连接成功时重试最多5次4001有独立重试计数器的设计值得特别关注这是为了应对compaction期间服务器短暂认为会话不存在的瞬态错误。如果4001共用通用重连预算一次compaction导致的3次4001可能耗尽预算后续真正的网络错误就无法重连了。5.3 权限跨端桥接最精妙的设计权限桥接是Remote系统最精妙的设计——远程容器需要执行工具但权限确认必须在本地TUI中弹出权限桥接流程 CCR容器执行工具 → ws发送 control_request { subtype:can_use_tool, tool_name, input } → RemoteSessionManager.handleControlRequest() → pendingPermissionRequests.set(request_id, request) → onPermissionRequest(request, request_id) → useRemoteSession.onPermissionRequest() → findToolByName(tools, tool_name) → createToolStub(tool_name) ← 关键工具桩 → createSyntheticAssistantMessage( ← 关键合成消息 request, requestId) → 入队 ToolUseConfirm → 本地TUI弹出审批对话框 用户本地审批 → onAllow(updatedInput) → manager.respondToPermissionRequest( requestId, { behavior: allow }) → SessionsWebSocket.sendControlResponse(response) → ws.send() → 回传到CCR容器两个关键技巧① createToolStub为远程工具创建一个最小的本地Tool桩。本地可能没有这个工具的完整定义远程端可能安装了不同的工具集但权限UI只需要工具名称和输入参数就能展示审批对话框。桩对象提供了刚好够用的接口避免了在两端同步完整工具注册表。② createSyntheticAssistantMessage构造一条假装来自本地Agent的AssistantMessage。这条合成消息包含工具调用信息让权限审批UI与本地工具调用的UI保持完全一致。用户在审批远程工具调用时看到的界面和审批本地工具调用完全相同——这种透明化是Bridge架构的核心价值。设计模式合成消息统一入口。当需要在不同上下文本地/远程中复用同一交互流程时可以通过构造合成消息将外部事件伪装成内部事件共享同一处理管线和UI路径。这避免了为远程场景开发独立的交互流降低了维护成本并保证了体验一致性。六、Token生命周期管理6.1 主动刷新调度器Bridge不等Token过期后再刷新而是提前5分钟主动刷新createTokenRefreshScheduler({ refreshBufferMs 5min }) schedule(sessionId, jwt): → 解码JWT exp claim → 计算 delayMs (exp - now - 5min) - delayMs ≤ 0立即刷新 - delayMs 0: setTimeout(doRefresh, delayMs) doRefresh(sessionId): v1: handle.updateAccessToken(oauthToken) → 通过子进程stdin发送update_environment_variables → 特点热更新子进程不中断 v2: api.reconnectSession(envId, sessionId) → 让服务端重派带新JWT的工作项 → 特点冷更新触发rebuildTransportv1与v2的刷新方式差异值得深思v1是热更新——通过子进程stdin注入新的环境变量子进程无需中断v2是冷更新——触发rebuildTransport重新建立SSE连接v2的方式更安全新JWT直接用于新连接不存在旧JWT残留的风险但有短暂的断连窗口6.2 OAuth 401恢复链当SSE/WebSocket收到401错误触发精心设计的恢复链SSE/WS收到401 → recoverFromAuthFailure(): → 检查authRecoveryInFlight防重入 → onAuth401(staleToken) → 刷新OAuth Token → fetchRemoteCredentials() → 新worker_jwt epoch → rebuildTransport(newCredentials, auth_recovery) → seq transport.getLastSequenceNum() // 继承序列号 → transport.close() → transport createV2ReplTransport({...}) → wireTransportCallbacks() → transport.connect() → drainFlushGate() ← 排空等待中的消息关键竞态防护authRecoveryInFlight标志用于串行化401恢复与定时刷新。考虑以下场景Token在5分钟缓冲期内过期可能时钟偏移导致SSE触发401恢复同时定时刷新也被触发如果两者并发执行可能双发/bridge请求导致epoch不一致HTTP 409冲突。authRecoveryInFlight确保同一时刻只有一个恢复流程在执行。6.3 跨进程退避initReplBridge.ts中还实现了更高层次的保护——跨进程的OAuth退避跨进程退避机制 进程AOAuth刷新彻底失败 → 写入GlobalConfig: bridgeOauthDeadExpiresAt now backoff_period bridgeOauthDeadFailCount count 1 进程B准备刷新OAuth → 读取GlobalConfig → if (now bridgeOauthDeadExpiresAt) → 跳过刷新避免重复尝试这种通过共享配置实现跨进程协调的模式虽然简单但在实践中非常有效——防止了多个Bridge进程同时反复尝试失败的OAuth刷新。七、子进程孵化与NDJSON协议7.1 子进程通信模型在v1模式下每个远程会话对应一个本地CLI子进程。sessionRunner.ts使用NDJSON协议通过stdio通信桥接主进程 —stdin→ 子进程 update_environment_variables (Token刷新) claude --print --sdk-url url --session-id id --input-format stream-json --output-format stream-json 子进程 —stdout→ 桥接主进程 assistant工具调用/文本响应 user用户消息replay result会话结束信号 control_request权限请求 子进程 —stderr→ 环形缓冲区10行为什么选择NDJSON over stdio而不是其他IPC方式跨平台stdio在所有操作系统上都可用Unix socket在Windows上不原生支持进程隔离子进程崩溃不会拖主进程共享内存有此风险可调试NDJSON是文本协议stderr输出可以直接阅读自然的流控stdio pipe有内核级别的缓冲区天然提供背压控制7.2 活动状态提取Bridge主进程通过解析子进程stdout中的NDJSON消息提取活动信息子进程stdout → NDJSON解析器 → 消息分类 assistant消息 → 提取工具调用状态 tool_use block? → 更新正在执行 tool_name text block? → 更新正在思考... result消息 → 会话结束信号 → 触发会话归档 工作项释放 control_request消息 → 权限请求 → 路由到远程端的权限审批UI7.3 进程终止策略优雅关机序列 ① SIGTERM → 子进程 ← 请求优雅退出 ② 等待graceful窗口30s ← 给子进程时间清理 ③ SIGKILL → 顽固子进程 ← 强制终止 ④ archiveSession() ← 归档会话 ⑤ removeAgentWorktree() ← 清理git worktree若有 ⑥ stopWork() ← 释放工作项30秒的优雅窗口是深思熟虑的选择Claude Code的工具执行如git commit、npm install可能需要几秒钟完成但不太可能超过30秒。Windows平台有特殊情况——child.kill()不支持信号名称参数SIGTERM和SIGKILL的行为可能相同Bridge通过先试后杀的双阶段策略来应对。八、控制协议云端与本地的精确交互8.1 控制请求类型方向Subtype语义超时Server→Clientinitialize会话初始化返回能力列表~10-14sServer→Clientset_model切换当前模型~10-14sServer→Clientset_max_thinking_tokens调整思考Token上限~10-14sServer→Clientset_permission_mode切换权限模式~10-14sServer→Clientinterrupt中断当前turn~10-14sClient→Servercan_use_tool工具使用权限请求用户操作两个不对称性方向不对称大部分控制请求是服务端远程发给客户端本地只有can_use_tool是反方向的。这反映了Bridge的核心原则——远程端是控制器本地端是执行器审批者。超时不对称系统控制请求有固定超时10-14秒而权限请求的超时取决于用户操作。系统控制是自动化的应该很快完成而权限审批需要等待人类决策。8.2 超时防御服务器发送control_request后如果客户端未在超时窗口内响应服务器将断开WebSocket连接。handleServerControlRequest对此有一个防御性设计functionhandleServerControlRequest(request){switch(request.subtype){caseinitialize:returnhandleInitialize(request)caseset_model:returnhandleSetModel(request)// ... 其他已知类型default:// 未知类型也返回error防止超时断连return{type:error,message:Unknown subtype:${request.subtype}}}}未知请求类型在协议演进中是常见的服务端升级后可能发送新类型的控制请求静默忽略会导致超时断连而返回error至少保持了连接的存活。8.3 outboundOnly模式当outboundOnly true时CCR Mirror模式除initialize外所有可变请求返回error。使用场景是展示/演示——你可能想让别人看到你的编码过程但不希望他们能中断你的执行或切换模型。九、横向对比Bridge/Remote的架构评价9.1 值得学习的四个模式模式解决的问题核心思想有界幂等性检查BoundedUUIDSet有界去重内存不无限增长环形缓冲区SetO(capacity)内存O(1)操作冷热路径切换门控FlushGate批量与增量同步的交错排序先缓冲后排空保证消息时序合成消息统一入口跨上下文复用交互流程伪装外部事件为内部事件共享UI和逻辑路径双策略恢复快速恢复安全兜底乐观尝试悲观fallback先快后稳9.2 值得警惕的四个架构问题① 巨型闭包风险initBridgeCore函数体超过1,800行内部闭包变量20个。状态散布在transport、currentWorkId、lastTransportSequenceNum、flushGate等变量中没有显式状态机。对比第3章的QueryEngine——通过AppStateStore将状态外部化使状态可被观测和测试。Bridge缺少这种外部化调试困难。② 缺乏单元测试bridgeMain.ts、replBridge.ts、remoteBridgeCore.ts、SessionsWebSocket.ts均无直接单元测试文件。对比第8章讨论的安全系统——每个Validator都有独立的可测试边界。Bridge的核心逻辑缺乏这种可测试性设计。③ v1/v2分叉的债务积累消息写入、去重、teardown逻辑在两个核心文件中各有一份实现维护代价随时间线性增长。④ 重连策略不一致Bridge侧使用指数退避2s→4s→60sRemote侧使用固定延迟2s×5次缺乏统一的退避策略抽象。对比第12章讨论的API层统一withRetry引擎——Bridge/Remote系统缺少同等级别的退避策略复用。十、实战启示Agent远程化的五条建议综合整个Bridge/Remote系统的架构分析以下五条洞察对任何Agent远程化设计者都有参考价值洞察1先问状态归谁所有再决定通信架构。Bridge的所有设计决策都源于一个根本答案状态属于本地。正是这个决定导出了语义桥接而非终端转发、“尽力同步而非可靠队列”、“断连不中断执行等一系列推论。如果你的Agent选择状态属于云端”整个通信架构将完全不同。洞察2权限模型决定了远程化的天花板。如果你的Agent需要用户逐个审批工具调用那远程化方案必须保留请求→审批→执行的三步结构需要双向控制协议。如果你的Agent是完全自主的如CI中的自动修复远程化可以简化为单向的状态通知。洞察3消息去重必须分层因为重复来源不同。一层去重无法覆盖所有来源。设计远程通信时先枚举所有可能的重复来源传输重放、Echo回环、批量/增量交错再为每个来源设计对应的去重策略。洞察4消息协议应从第一天就考虑去重。Bridge的三层去重部分是后加的补丁。如果协议从一开始就定义全局单调序列号和幂等性语义可以用一层解决三层的问题。教训是不要在应用层修补传输层的不可靠性而是在协议层就定义清楚。洞察5远程端应该是观众而非演员。将远程端设计为只读观察者受限的控制通道而非完全对等的执行端。这简化了一致性问题只有一个写入者降低了网络分区的影响远程端断连不影响执行也使权限模型更清晰本地是唯一的信任锚点。十一大设计模式速查模式核心思想本章出处状态所有权决定通信架构远程化方案的第一个架构决策§13.1.3有界幂等性检查BoundedUUIDSet固定内存的O(1)去重§13.3.3冷热路径切换门控FlushGate批量与增量同步的交错排序§13.3.3合成消息统一入口跨上下文复用交互流程§13.5.3双策略恢复快速恢复安全兜底§13.4.3分层去重多来源消息重复的分治防御§13.3.3渐进式架构演进的陷阱版本并存的债务管理§13.2.3心跳模式保活容量满载时的优雅降级§13.4.2防御性控制请求处理未知subtype返回error而非静默§13.8.2跨进程退避共享配置实现跨进程协调§13.6.3选择性不清理持久模式的快速恢复设计§13.4.4下期预告第19篇将进入附录AC22个架构模式速查 System Prompt全链路拆解。附录A22个架构模式按场景分类速查分布式任务类、状态管理类、安全防御类、扩展接入类、上下文管理类、容错弹性类附录CSystem Prompt全链路三层装配流程静态数据→动态注入→用户指令优先级计算buildEffectiveSystemPromptCLAUDE.md编写实战指南从远程突破回到模式总结——13章的架构深度探索后是时候退后一步把散落各处的设计模式系统化地收拢了。思考题如果Claude Code选择状态属于云端类似Codespaces模式Bridge/Remote的架构会如何变化哪些当前的设计决策尽力同步、FlushGate、权限桥接会被替换会引入哪些新的挑战