s-blog

深扒 Claude Code 源码(六·完结):Remote Control、记忆与遥测开关体系

手机远程驱动本地 CLI 的桥 / 系统提示缓存边界与自动记忆 / 三层开关治理

ssssmy · 2026-06-11 · 13 min · LLM

本文是《深扒 Claude Code 源码》系列完结篇,收束 Remote Control、记忆与遥测治理三块。承接第五篇·UI 与多 Agent,全景与评估见总纲篇。本篇三章极密集,已浓缩极细节、保留全部小节。

12. Remote Control 与远程会话

12.1 Remote Control 是什么

Remote Control 让用户从手机或网页(claude.ai)远程驱动一台机器上的本地 CLI——在开发机上跑 claude remote-control,本地进程把自己注册成可被云端调度的 worker,云端把"会话"作为 work 投递下来,本地 spawn 真正的 claude 子进程执行,输出回传到 web/mobile。

它是订阅专属功能isBridgeEnabled() 同时要求 isClaudeAISubscriber() 与 GrowthBook gate tengu_ccr_bridge。原因是 bridge 用 claude.ai OAuth token 向 CCR 认证,而 Bedrock/Vertex、apiKeyHelper、env-var API key 都没有这枚 OAuth token。getBridgeDisabledReason() 把失败拆成四档可执行错误(非订阅、token 缺 user:profile scope、organizationUuid 缺失、gate 未开),特意区分"缺 profile scope"档——长效 token 拿不到 organizationUUID 会导致 gate 永远 false 且用户看不出"重新登录就能修"。

12.2 v1 env-based:Environments API 轮询 + WorkSecret

v1 路径走 Environments API 的 poll/dispatch 模型。WorkSecret 是核心数据结构(base64url 编码的 JSON),随每个 work item 下发,含 session_ingress_token(后续 session 级 API 调用的 JWT)、claude_code_args、以及 server 驱动的 use_code_sessions 选路开关。轮询循环工程上极细致:work 去重防重复 spawn、ack 时机(committing to handle 之后才 ack,否则 at-capacity guard 会永久丢 work)、token 三态分离存储(refresh scheduler 会覆盖 accessToken,而 heartbeat 需要原始 ingress JWT)、心跳与轮询"组合"而非"互斥"。

12.3 v2 env-less:POST /bridge 直换 worker_jwt + SSE

v2(仅 REPL 用,由 tengu_bridge_repl_v2 门控)把 register/poll/ack/stop 全砍掉,换成五步直连:POST /v1/code/sessions → POST /bridge{worker_jwt, worker_epoch} → 建 SSE transport → token refresh scheduler → SSE 401 重建。

工程难点全在 epoch 一致性:每次 /bridge 都 bump epoch,只换 JWT 不重建 transport 会让老 CCRClient 拿 stale epoch 心跳 → 20s 内 409。所以两条刷新路径都重建 transport,并用 authRecoveryInFlight flag 在任何 await 之前同步抢占——笔记本唤醒时 overdue 的 proactive timer 和 SSE 401 会几乎同时触发。worker_jwt 刻意不写进 process.env,因为 mcp/client.ts 会无门控地读这个 env 并发给用户配置的 MCP server,等于泄露 worker JWT。

12.4 pollConfig:Zod refine 防误配紧轮询

pollConfig.ts 是教科书级的"配置即代码防御":从 GrowthBook 拉 JSON,任一字段违规则整个对象回退默认(reject whole object 而非 clamp)。三层防御:seek-work 间隔 .min(100);at-capacity 间隔用 0-or-≥100 refine(1-99 被拒,专门挡"以为填秒填了 10、实际每 10ms 打一次 DB"的单位混淆);对象级 refine 强制"at-capacity 至少有一种 liveness 机制",挡 hb=0, atCapMs=0 穿透所有 throttle 以 HTTP 速度紧轮询 DB 的 drift config。

12.5 upstreamproxy:CCR 容器侧 MITM 出站代理六步

upstreamproxy.ts 让 CCR 容器内 curl/gh/python 的出站 TLS 经过服务端 MITM 注入组织凭据。六步:读 /run/ccr/session_tokenprctl(PR_SET_DUMPABLE, 0) 防同 UID gdb -p 从堆里 scrape token → 下载拼接 MITM CA bundle → 起 CONNECT→WebSocket relay → 只在 listener 起来后才 unlink token(让 supervisor 重启能重试)→ 导出 HTTPS_PROXY/SSL_CERT_FILE 等。

全链 fail-open 是设计基石:“a broken proxy setup must never break an otherwise-working session”。NO_PROXY 对 anthropic.com 刻意写三种形式*.anthropic.com/.anthropic.com/anthropic.com),因为 Bun/curl/Go 用 glob、Python urllib 用后缀匹配、apex 兜底——各 runtime 的 NO_PROXY 解析不同,且 MITM 会破坏非 Bun runtime 的 cert 校验。

12.6 relay:protobuf 穿 GKE ingress 的 CONNECT-over-WebSocket

核心问题:CCR ingress 是 GKE L7 path-prefix 路由,没有 connect_matcher,裸 HTTP CONNECT 穿不过去,必须把字节裹进 WebSocket。手写 protobuf:字节裹进 UpstreamProxyChunk { bytes data = 1 },手写 wire format(tag + varint 长度 + bytes)只要 10 行,避免热路径上的运行时依赖。双重认证:WS upgrade 带 Bearer <token>,隧道内 CONNECT 第一帧再带 Proxy-Authorization: Basic。容器实际跑 Node,Node 路径用 ws 包 + 显式 agent 经 egress 代理。

12.7 remote 客户端侧:合成 AssistantMessage 复用本地权限 UI

消费端(看远程会话的 viewer)与 bridge 对称。SessionsWebSocket 按 close code 分类重连(4003 永久停、4001 session-not-found 有限 3 次、其余 transient 5 次)。

remotePermissionBridge 是最精巧的 trick:本地权限组件 ToolUseConfirm 要求一个真实的 AssistantMessage,但远程模式下工具实际跑在 CCR 容器、本地根本没有真消息。createSyntheticAssistantMessageSDKControlPermissionRequest 现造一条 assistant 消息(含单个 tool_use block),createToolStub 为本地不认识的工具造最小 stub,再 push 进与本地会话完全相同的 toolUseConfirmQueue。于是远程会话的权限弹窗复用了本地那套权限 UI——用户看到的 dialog 跟本地工具调用一模一样,批准/拒绝后决策经 WS control_response 回到容器。这套合成消息 + queue 复用是整个远程权限体验"无缝"的工程支点。


13. 系统提示、持久化与记忆

本章覆盖三条交织的数据流:每轮请求如何组装系统提示(含缓存边界)、context 如何以 isMeta 消息注入、会话 JSONL 持久化与 resume,以及附件管线与 memdir 自动记忆。

13.1 系统提示组装:section 化 + 静态/动态缓存边界

getSystemPrompt 返回一个 string[](提示块数组),不是单个字符串——为了让下游 splitSysPromptPrefix 能逐块打缓存标记。整个提示被人为切成"静态可缓存前缀"与"动态后缀"两段,用一个哨兵字符串 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 分隔。

动态段是一个 section 注册表,非 cacheBreak 的项一旦算过就缓存直到 /clear/compact。核心设计动机:任何运行期布尔位若放在边界之前,会让 cacheScope:'global' 前缀的 Blake2b 哈希分裂成 2^N 个变体,所以把所有"会话变体"逻辑下沉到边界之后。mcp_instructions 是唯一的 uncached section(MCP server 会在 turn 之间连接/断开),但 gate 检查放在 compute 内部而非两个变体间选择,避免 mid-session gate 翻转读到陈旧缓存。

13.2 userContext / systemContext:会话级 memoize 与 isMeta 注入

两个 context 都是会话级 memoize。userContext 产出 {claudeMd, currentDate},经 prependUserContext 包成首条 user 消息isMeta:true),内容是 <system-reminder> 包裹的 CLAUDE.md + 日期。systemContext(git status)经 appendSystemContext 追加到系统提示数组末尾。

一个关键权衡:currentDate 在 messages[0] 里跨午夜故意保持陈旧——清这个缓存会重建整个 prefix,把整段对话变成 cache_creation(注释估算每次跨午夜 ~920K effective tokens)。日期变更改用尾部追加的 date_change 附件通知模型,不动缓存前缀。splitSysPromptPrefix 按缓存语义切块:有 MCP 工具时降级到 org 级,1P 且找到边界标记时 4 块(边界前静态用 cacheScope:'global' 跨 org 可缓存)。

13.3 claudemd.ts:多级加载、@include、条件规则

getMemoryFiles 的加载顺序即优先级(反序加载,越晚=越高优先级):Managed(policy)→ User → 从 cwd 向上走到 root 再 reverse 从 root 向下处理(Project/Local)→ --add-dirMEMORY.md(AutoMem)→ TeamMem。@include 递归处理(深度上限 5),用 marked Lexer 从 leaf text node 抽取——所以代码块里的 @path 不会被当 include,non-text 扩展名被拒防 PDF/图片入内存。frontmatter paths: 字段把规则变成条件规则,仅在 FileRead/Edit 命中目标路径时才注入。

13.4 sessionStorage(5105 行):JSONL 持久化、resume、rewind

存储模型:每会话一个 JSONL,append-only(removed 消息留在磁盘上),写路径用 writeQueue 定时批量 drain。parentUuid 链维护单链表,compact boundary 的 parentUuid 置 null 但 logicalParentUuid 保留原 parent——这是 --continue 链在 compact 处截断的机制。

resume 读路径对大文件有激进优化:fd 级跳过 attribution-snapshot 行(从不 buffer),峰值分配是输出大小而非文件大小(实测:151MB session 84% 是 stale attr-snap,分配 ~32MB 而非 159MB)。recoverOrphanedParallelToolResults 修复并行 tool_use 的 DAG 拓扑丢失:streaming 每个 content_block_stop 发一条同 message.id 不同 uuid 的消息,单父链表只保一支,故按 message.id 分组把 off-chain 的 sibling 补回。rewind 命令本身是 thin stub,靠 tombstone + append-only JSONL 上的"same-head shrink" + 文件历史快照实现。

13.5 附件管线:@-mention 与 compact 后恢复

getAttachments 每轮计算,分三组并行(user-input / thread / main-thread-only),每个 getter 用 maybe() 包裹做错误隔离。file 附件被渲染成一对伪造的 FileReadTool tool_use + tool_result,让模型以为自己读过。

compact 后恢复:compaction 把历史压成 summary,但被 @-mention 过的大文件不能塞进 summary。generateFileAttachment 在 compact 模式返回轻量的 compact_file_reference(仅含 filename),渲染成 isMeta 提示告知模型"contents are too large to include. Use FileRead if you need it"——内容被丢弃但引用被保留。

13.6 memdir 自动记忆:MEMORY.md 入口、findRelevantMemories、TEAMMEM

入口上限MEMORY.md 受双重截断 MAX_ENTRYPOINT_LINES=200MAX_ENTRYPOINT_BYTES=25_000(字节 cap 抓"行数没超但长行撑爆"的索引,p100 实测 197KB/200 行)。记忆 prompt 强调 MEMORY.md 是 index 不是 memory,并配套写"This directory already exists"——因为实测模型会浪费 turn 跑 ls/mkdir -p

findRelevantMemories 每轮异步预取,在主模型流式输出+工具执行期间并行跑,绝不阻塞 turn。核心选择用 Sonnet sideQuery:先拿所有记忆的 filename+description header,规则明确——按 filename+description 选至多 5 个 clearly useful 的,不确定就不选,不选在用工具的 API 文档(对话里已在用)但仍选含 warning/gotcha 的。返回的 filename 用 validFilenames set 过滤幻觉。渲染时 header(age+path)在附件创建时预算并存储——render 时重算 memoryAge 会调 Date.now() 让"saved 3 days ago"变"4 days ago"使字节变、缓存失效。

记忆路径用 findCanonicalGitRoot 让同仓所有 worktree 共享一个记忆目录;autoMemoryDirectory 设置故意排除 projectSettings,防恶意仓库设 ~/.ssh 经写 carve-out 获取敏感目录写权限。


14. 遥测与特性开关体系

一套"双汇聚遥测管道 + 三层开关体系",同时承担灰度发布、成本控制、A/B 实验三个互相牵制的目标,并把代价转嫁到公开代码的死分支复杂度上。

14.1 遥测双汇聚:sink 路由与 PII 分级

事件入口 logEvent 刻意做到零依赖(避免 import 环)。路由:先采样(Math.random() < sampleRate,返回 0 直接 drop);Datadog 受 gate 控制 + 白名单(只约 50 个高价值告警事件进,因为 Datadog 按量计费);第一方 OTLP 始终发送(BigQuery 全量分析源)。

_PROTO_ 前缀的 PII 分级路由是最精巧的部分。带 _PROTO_ 前缀的键是 PII-tagged 未脱敏值,只允许进第一方那条受权限控制的 BQ 特权列。stripProtoFields 两处对称防御:发 Datadog 前剥离(通用后端绝不能见 PII);第一方侧先把已知 _PROTO_ 键 hoist 到专列,再对剩余部分再次剥离防未来某个未识别的 _PROTO_foo 静默落进通用 blob。

14.2 第一方 OTLP 导出器:OTel BatchProcessor + 自建韧性层

复用 OTel sdk-logs,建一个独立的 LoggerProvider 与客户遥测隔离。在 BatchLogRecordProcessor 上叠自建韧性层:失败事件落盘 JSONL(多数文件系统 append 原子)+ 二次方退避(base * attempts²,8 次后丢弃)、健康探测短路、成功即排空、跨进程重放(构造时扫描同 sessionId 不同 BATCH_UUID 的遗留文件捞回崩溃残留)、401 无 auth 降级、killswitch 每 POST 前探测(killed 时零网络流量但退避计时器继续走,flag 一清自动恢复)。

14.3 metadata 富化:单一真相源与 MCP 脱敏

getEventMetadata 是所有 sink 共享的富化源。关键工程决策env 显式声明为 proto 生成类型 EnvironmentMetadata,注释直言这是为了让"加一个 proto 没定义的字段就编译报错"——历史上手写并行类型导致四个字段 ship 出去却因 toJSON() 静默丢弃未知键而从未到达 BQ。这是用类型系统把 schema drift 变成编译期错误的范例。Datadog 侧降基数:mcp 工具名归一成 'mcp'、model 名归一到已知名或 'other'getUserBucket 把 userID 哈希分进 30 个桶(用唯一桶数估唯一用户数而不直接计 userID)。

14.4 GrowthBook 远程评估:缓存、曝光与 SDK 绕过

remoteEval: true(服务端预评估,客户端不下发完整规则集保护实验定义)。SDK 绕过是核心 workaround:GrowthBook SDK 在 remoteEval 下 evalFeature() 仍会本地重评估、忽略服务端值,所以代码自建 remoteEvalFeatureValues Map 绕过 SDK,并整体写盘供跨进程/初始化前读取。空 payload 有显式长度检查防瞬时服务端 bug 把磁盘缓存清空造成"全 flag 黑屏"。读取 API 有清晰的阻塞性分级,命名即契约:_CACHED_MAY_BE_STALE(纯磁盘读)、_BLOCKS_ON_INIT_CACHED_OR_BLOCKING(disk 说 true 就信、说 false 才 await fresh)、checkSecurityRestrictionGate(安全关键)。

14.5 三层开关体系的分工

三层各管一个时间尺度:

  • 第一层 feature()(编译期 DCE):决定"代码进不进二进制"。feature('X') ? require(...) : null 让整个模块从公开 bundle 消失。KAIROS 整个 assistant 子系统在外部构建里根本不存在(这也是还原树 src/assistant/gate.ts 缺失的根因)。静态、发版即固定。
  • 第二层 GrowthBook(运行期):决定"已存在的功能对这个用户开不开"。支持百分比放量、按 org/订阅层/email targeting、A/B 变体 + 曝光上报。
  • 第三层 Statsig 式动态配置(秒级熔断)tengu_frond_boric(sink killswitch)、各种 tengu_*_config。无需发版的运行时止损:线上出问题改一个远程配置就能停掉某条遥测路径、降采样率。

KAIROS 是三层协作的样例feature('KAIROS') 决定代码在不在,tengu_kairos gate 决定本会话开不开,setKairosActive(true) 反馈给 metadata 富化。

14.6 对灰度/成本/A-B 的支撑与死分支代价

支撑清晰:灰度由 GrowthBook 放量 + feature() 二进制隔离双保险;成本由 Datadog 白名单 + 基数归一 + 可调采样率 + 双 sink 分工;A/B 由 remoteEval + 曝光上报 + cohort 切分支撑。

代价显著:feature('KAIROS') 散布 156 处、TRANSCRIPT_CLASSIFIER 110 处,全是对外恒为 false、被 DCE 抹掉但在源码里清晰可读的死分支;对应 ant-only 模块整体剥离导致还原树结构性缺口。本质上这是用"代码可读性和维护成本"换"发版灵活性、二进制安全、运行时止损能力"——对一个面向数百万用户、需要频繁灰度且部分功能仅内部可见的 CLI 而言这笔交易是理性的,但它解释了为何逆向出的源码树充斥着永不执行的分支和精巧到反常的缓存绕过逻辑。


系列结语

至此六篇走完 Claude Code 的 14 个核心子系统。回看全程,有一条线索反复浮现:这份代码的优秀不是设计得漂亮,而是被真实世界磨出来的——几乎每个魔数背后都有一条 BigQuery 注释,每个绕路都对应一个 incident 编号,每处缓存偏执都为了护住 prompt cache 命中率这个直接换算成钱和延迟的指标。

它把"和不可靠 LLM 协作"的每个现实难点——token 溢出、模型越界、缓存稳定、冷启动、终端渲染、远程驱动、多 agent——都正面啃了下来。代价是复杂度逼近单人可掌握的上限,以及大量 ant-only 死分支渗进公开代码。做能用的 agent demo 很容易,做敢交付的 agent 要多一个数量级的工程,这份代码就是那个数量级的样子。

它既是范本,也是警示。需要提醒的是:本系列剖析的是一份从 source map 还原的源码树,对研究内部实现价值很高,但因依赖镜像重建、原生能力 shim、类型层缺失而不可作生产源码——详见总纲篇的还原缺口附录。

—— 全系列完

原文链接:https://www.ssssmy.com/notes/claude-code-deep-dive-remote-memory-telemetry