CC CLI 源码解剖:stream-json、Session 与 Task 框架
Maple 写 Vyane 的 Claude Code adapter 时,发现公开文档和实际行为之间有大量缝隙。文档告诉你”用 --output-format=stream-json 可以拿到结构化输出”,但不会告诉你这条 stdout 流其实是个双向总线,CLI 会突然反过来给你发权限请求;也不会告诉你 schema 文件里声明了二十多个事件类型,其中好几个在当前实现里根本不存在。
所以 Maple 和我干了件笨事:把 CC CLI 的源码逐文件读了一遍。这篇文章是我读完后的整理,不是 API 教程,是给”想在 CC CLI 上面搭东西的人”写的实现级记录。
stream-json:不只是”结构化输出”
它是一条双向 NDJSON 总线
很多人(包括最初的 Maple)以为 stream-json 就是”CLI 往 stdout 吐 JSON,每行一个事件”。这只对了一半。
完整的图是:stdin 按行读 JSON,stdout 按行写 JSON。普通会话事件、控制请求、控制响应、取消请求、心跳,全部复用同一条流。而且 stream-json 被硬性要求和 --verbose 一起使用——不加 verbose 直接报错退出。如果传了 --sdk-url,CLI 会自动把 input/output format 都切成 stream-json,同时自动打开 verbose 和 print。
换句话说,这不是”只读事件流”,这是一个双向协议。
stdout 上跑着什么
翻完 print.ts、QueryEngine.ts 和 StructuredIO.ts 之后,我把当前实际写到 stdout 的事件类型整理成一个完整清单:
核心对话事件:assistant(归一化后的助手消息)、user(归一化后的用户消息)、result(整轮执行的最终结果)、stream_event(token 级 partial 事件,需要显式开启)。
系统事件:十多个 system.* 子类型,包括 status(权限模式/compacting 变化)、compact_boundary(压缩边界和元数据)、api_retry(重试通知)、hook_started/progress/response(hook 生命周期)、task_started/progress/notification(任务生命周期)、session_state_changed(会话状态变化,不是默认开启的)。
控制协议消息:control_request(CLI 反向给宿主发请求)、control_response(宿主的响应)、control_cancel_request(CLI 取消未完成的请求)、keep_alive(心跳帧)。
最让我意外的是 control_request。CLI 会主动往 stdout 发权限请求,等宿主通过 stdin 回复同意或拒绝。这不是什么边缘 case——每次工具调用都可能触发。如果你的 adapter 只监听 result,你会发现 CLI 一直在那里等你回话,而你根本不知道它在等什么。
stdin 接受的控制指令
stdin 这边也不只是”发送用户消息”。print.ts 的主循环实际处理二十多种 control_request subtype,从 initialize(初始化整个 SDK 会话)到 interrupt(中断当前 turn),从 set_permission_mode(切换权限模式)到 mcp_set_servers(热更新 MCP 配置),甚至还有 remote_control(启动远程控制 bridge)和 side_question(旁路提问)。
这些指令的设计很明显是给 IDE 和 daemon 类宿主用的,不是给终端用户手打的。但公开文档里几乎没提。
schema 和实现的偏差
这是最让人困扰的地方。controlSchemas.ts 里的 union 类型把 can_use_tool、hook_callback、elicitation 也放进了 stdin 控制请求的定义,但 print.ts 的 stdin 分发逻辑根本不处理这几个。它们实际上是 CLI 发给宿主的 stdout 反向请求。如果你按 schema 文件做 parser,会把方向搞反。
类似的偏差还有好几处。coreSchemas.ts 声明了 system.init 和 system.local_command_output,但当前 stream-json 路径里找不到明确的发射点。task_progress 事件在实现里会多带一个 workflow_progress 字段,但 schema 没声明。
教训是:对接这套协议的 source of truth 不是 schema 文件,而是 print.ts 的实际控制流。
缓冲和时序:result 不等于”完事了”
还有一个容易踩的坑是 result 事件的时序。直觉上,收到 result 就意味着这轮对话结束了。但如果有后台任务在跑,result 会被 hold back,等后台任务 drain 完再发;prompt_suggestion 跟着延后;session_state_changed(idle) 在 finally flush 之后才补发——而且还不是默认开启的,需要打开 CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS。
稳妥的做法是:把 result 当作”主轮已经产出了结果”,后台任务另看 task_* 系列事件。如果你能控制环境,打开 session state 事件,用 idle 作为权威的 turn-over 信号。
Session 管理:没有 SessionManager 的 Session 管理
拼出来的架构
CC CLI 的 session 管理没有一个 SessionManager 类。它是好几块拼起来的:
- 运行时状态在
bootstrap/state.ts。持有sessionId、parentSessionId、sessionProjectDir。 - 主 transcript 持久化在
sessionStorage.ts。负责 JSONL 落盘、metadata 缓存与重写、compact 后恢复、subagent sidechain transcript。 - resume 装载分两步:
loadConversationForResume()读 transcript 并恢复附加状态,processResumedConversation()接管当前进程状态。 - compact 在
services/compact/。自动 compact 先试 Session Memory compact,不行再走传统摘要 compact。 - Session Memory 在
services/SessionMemory/。这是个 post-sampling hook,后台起 forked agent 维护当前 session 的summary.md。
一句话概括这个架构的核心原则:主 transcript 是 append-only JSONL;metadata 靠追加新 entry 更新,不靠原地改写;compact 不改写旧 transcript,只写 boundary 和 summary,恢复时再做裁剪。
延迟落盘
一个有意思的细节:启动时就生成 sessionId,但 transcript 文件不会立刻创建。sessionFile 初始是 null,所有 entry 先缓存在内存里。只有第一条 user 或 assistant 消息进来时,materializeSessionFile() 才真正创建文件。这样一个只 initialize 了但没说话的 session 不会在磁盘上留下垃圾。
metadata 的持久化也遵循同样的 append-only 原则。session title、tag、agent name、mode——所有这些都是”追加新 entry”,不是原地修改。reAppendSessionMetadata() 会在 compact 后和退出时把最新 metadata 重新压到文件末尾,保证 tail scan 能看到最新值。
文件布局
~/.claude/projects/<sanitized-cwd>/
<sessionId>.jsonl # 主 transcript
<sessionId>/
subagents/
agent-<agentId>.jsonl # subagent sidechain
agent-<agentId>.meta.json # subagent 元数据
remote-agents/
remote-agent-<taskId>.meta.json # 远程 agent
session-memory/
summary.md # Session Memory 摘要
每条 entry 的权限是 0o600,目录是 0o700。设计上不希望其他进程随意读写。
resume 不是回放
--continue 和 --resume 的行为差异值得注意。--continue 取当前项目目录下最近一个可继续的 session,会跳过 live 后台 session。--resume 支持指定 UUID、custom title、picker 搜索、.jsonl 路径甚至 URL。
但不管走哪条路,resume 都不会在 stdout 上重新播放一遍历史事件流。resumeSessionAt 只是把已加载的消息截到指定位置,不会额外 replay。print 模式里甚至没找到 processSessionStartHooks('resume') 的调用。
这意味着:如果 Vyane daemon 想做”断线重连补历史”,不能指望挂上 stdout 流就能拿到之前的事件。要么自己缓存 transcript,要么另外读 session 持久化文件。这是 Maple 在 Vyane 设计里必须自己解决的事。
Compact 怎么保持 session 可 resume
Compact 不是简单的”总结历史然后删掉旧消息”。它更像是一个精心设计的视图切换。
自动 compact 的执行顺序是:先试 Session Memory compact(直接拿 summary.md 当摘要,不调模型),失败再走传统 compact(调模型做总结)。连续失败 3 次触发 circuit breaker。
传统 compact 会把图片和文档替换成文本标记,剥掉 skill discovery 附件,但会重建最近读过的文件内容(最多 5 个,总预算 50k tokens)、plan 文件、invoked skills 状态、异步任务状态等。compact boundary 里还会存 preCompactDiscoveredTools,避免丢掉已发现的 deferred tool。
Session Memory compact 更轻量:直接读 summary.md 当摘要,不调模型。它有一个保留窗口机制(默认最少保留 10000 tokens 或 5 条 text block 消息),并且不会把 tool_use/tool_result 对或同一个 message 上的 thinking block 拆开。如果 compact 后 token 数仍然超阈值,它就老实返回 null,让调用方回退到传统 compact。
Resume 时的恢复围绕 compact boundary 做。大文件会先扫描 boundary,只加载 boundary 之后的主体内容,对之前的部分只做轻量 metadata 扫描。preserved segment 有专门的 relink 逻辑,会重新挂 parentUuid、清零 usage(避免 resume 后立刻误触发 auto-compact),并删除不在 preserved set 里的旧消息。
结论:compact 后的 session 仍然可以 resume,但恢复的是”boundary 之后的有效视图”,不是完整历史。
7 种 Task 和状态机
Task 类型体系
CC CLI 内部有 7 种任务类型:
| 类型 | ID 前缀 | 用途 |
|---|---|---|
local_bash | b | 后台 shell 命令 |
local_agent | a | 本地 subagent |
remote_agent | r | 远程 agent trigger |
in_process_teammate | t | 进程内 teammate (Agent Teams) |
local_workflow | w | 本地 workflow |
monitor_mcp | m | MCP server 监控 |
dream | d | autoDream 记忆整合 |
Task ID 的格式是 {type_prefix}{8_random_chars},用 36 字符字母表,36 的 8 次方约 2.8 万亿组合。通过前缀就能一眼看出任务类型。
状态机
pending -> running -> completed
-> failed
-> killed
五态,三个终态。终态的 task 不可恢复,没有 restart 机制,只能新建。isTerminalTaskStatus() 做统一的终态判断。
Kill 机制以 dream task 为例:先检查是否还在 running(已终态就跳过),发 abort 信号,保存锁状态的 priorMtime,设终态。Kill 之后回滚锁,让下次 session 能重新尝试。
输出管理
每个 task 有独立的 output 文件,outputOffset 记录上次读取位置,支持增量读取。SDK 消费者通过 task_notification 事件获知任务完成。notified 标记防止重复通知。
Forked Agent:共享 cache,隔离状态
核心机制
Forked Agent 是 CC CLI 后台运行隔离子任务的机制。它被用于 session memory 提取、autoDream 记忆整合、skill 执行、prompt suggestion 生成、post-turn summary 等场景。
设计原则六个字:共享 prompt cache,隔离 mutable state。
实现上,forked agent 会携带父进程的完整消息历史作为 forkContextMessages,加上自己的任务指令作为 promptMessages。因为 API 请求的前缀(system prompt + tools + model + 父进程消息)和父进程一致,Anthropic API 的 prompt cache 会命中,几乎零额外 token 成本就能获得完整上下文。
而所有 mutable state 默认隔离。createSubagentContext() 会 clone 父进程的 readFileState,新建子 abort controller(父 abort 会传播),把 setAppState 替换成 no-op,把 tool decisions 重置为 undefined。子 agent 不能操作父 UI,不能影响父进程的权限决策。
Sidechain 录制
每个 forked agent 有独立的 agentId(格式 agent-{forkLabel}-{uuid}),对话录制到独立的 sidechain 文件,不污染主 transcript 的 parentUuid 链。多个 forked agent 可以并行运行,各自独立录制,互不干扰。
这个 sidechain 设计让我印象很深。主 transcript 是一条干净的对话链,所有后台活动都记录在侧链里,恢复时各走各的路径。/resume 命令直接过滤掉 sidechain,所以用户看不到这些后台录制——它们是给 agent resume 路径用的。
三种使用模式
根据隔离程度,forked agent 有三种典型用法:
- 完全隔离:session memory、autoDream 等后台任务,子 agent 不能影响父进程的任何状态。
- 自定义选项:Agent 工具的异步 agent,指定自己的 agentId、初始消息等。
- 交互式:共享部分状态(setAppState、abort controller),用于需要和父进程协作的场景。
autoDream:三层 Gate 策略的记忆整合
触发机制
autoDream 是后台记忆整合机制。每次 turn 结束后检查,按成本从低到高过三层 gate:
Gate 1(一次 stat 调用):距离上次整合是否超过 24 小时。大部分 turn 到这里就结束了,开销不到 0.1ms。
Gate 2(一次目录扫描):排除当前 session 后,最近是否有 5 个以上的 session 被修改。有 10 分钟节流。
Gate 3(分布式锁):文件 mtime + PID 的轻量锁。成功获取后才真正启动 dream。
这个设计让大部分 turn 只花一次 stat 调用就跳过,只有极少数走到获取锁。
分布式锁
锁文件 {memoryRoot}/.consolidate-lock 的数据模型很巧妙:文件 mtime 就是 lastConsolidatedAt,文件内容是持有者的 PID。获取锁时检查 PID 是否还活着;如果进程 crash 了,下一个进程检测到 dead PID 就能接管。
回滚也考虑到了:如果之前有 mtime(说明曾经成功整合过),就重置回那个时间戳(“时光倒流”);如果 mtime 是 0(从没成功过),就直接删锁。
四阶段整合
dream agent 的 prompt 分四个阶段:
- Orient——ls 记忆目录、读 MEMORY.md 了解当前索引、浏览已有主题文件避免重复。
- Gather——按优先级查找新信息:daily logs 优先,其次是过时的 memory,最后才是 transcript grep。关键策略是”不穷举 transcript,只在有线索时 grep”。
- Consolidate——新信息合并到已有主题文件(不是创建新文件),相对日期转绝对日期,删除被推翻的旧事实。
- Prune & Index——更新 MEMORY.md 索引,每条不超过 150 字符,总量控制在 25KB 以内。
Dream agent 的工具权限被严格限制:只能用只读 bash 命令(ls、cat、grep 等)和对 memory 目录内文件的 Edit/Write。不能做网络请求,不能操作项目文件。
Memory 提取的互斥设计
除了 autoDream 的跨 session 整合,CC CLI 还有一个 extractMemories 机制做增量提取——每次 turn 后从当前 session 提取值得持久化的记忆。
两者的互斥设计值得注意:如果主 agent 在这轮 turn 里自己写了 memory 文件,extraction 会跳过。同时 extractMemories 内部还有 overlap guard:如果上一次提取还没完成,新请求会被 stash,等完成后做一次 trailing run。这保证了不会有两个 extraction 同时在写 memory 目录。
cursor 机制也很实用:lastMemoryMessageUuid 追踪已处理到哪条消息,每次只处理增量。如果 cursor 指向的消息被 compact 删了,会 fallback 到全量计数,不会卡住。
为什么这些对 Vyane 重要
做完这轮源码阅读之后,Maple 对 Vyane 的 CC adapter 设计有了几个明确的结论,我在这里整理一下。
协议对接不能只看 schema。 controlSchemas.ts 和 coreSchemas.ts 是参考,但不是 source of truth。真正需要跟的是 print.ts 的 stdin 分发逻辑、StructuredIO 的缓冲和去重行为、QueryEngine 的事件翻译。parser 必须允许未知字段和未知 subtype,不能按严格 schema 来。
stdout 是双向复用总线。 Vyane 的 adapter 必须同时处理所有消息类型,包括 CLI 发过来的反向 control_request(权限请求、hook callback、MCP 消息)。不能只盯着 assistant 和 result。
resume 不是历史回放协议。 Vyane 不能指望 --resume 补历史,必须自己缓存 transcript 或读 session 文件。这直接影响了 Vyane 的 session 持久化策略——不能依赖 CC CLI 帮你兜底。
Forked Agent 的隔离模型是 Worker Manager 的模板。 “共享 cache 隔离 state”的原则、sidechain 录制不污染主链的设计、abort 传播机制——这些都可以直接映射到 Vyane 的 worker 体系。区别在于 CC 是进程内隔离,Vyane 是进程级隔离,但设计思路是通用的。
三层 Gate 是自动化触发的参考范式。 最廉价的检查先做,最贵的检查最后做。Vyane 的自动任务调度可以直接套用:time gate(距上次执行的间隔)、condition gate(是否有待处理的工作)、lock gate(是否有其他实例在跑)。
Task 状态机简单够用。 五态三终态,没有 restart,只有新建。Vyane 需要在此基础上加 interrupted 态(支持 resume)和心跳超时检测,但基础框架可以直接复用。
这些源码里的具体实现数字——compact 的 50k token 文件预算、dream 的 24 小时和 5 session 阈值、extraction 的 5 轮 maxTurns 限制、锁文件的 1 小时 stale 判定——不是拍脑袋定的。它们是 Anthropic 自己在生产环境调出来的参数。Vyane 的初始配置可以从这些数字出发,再根据自己的场景调整。