ZCode Protocol V4:Agent 对外的线协议
桌面端与 Web 服务端怎样经 stdio 驱动 Agent 子进程:NDJSON 帧与 stdout 保护,版本与握手,V4 方法与 34 种命令,CommandInbox 的串行受理与幂等,快照续传,两种投递档位,交互应答竞速,stale 防护与会话驻留。
上一篇的终端界面与运行时同进程。桌面端与 Web 服务端不这样:它们的 Host(packages/services 里的 Agent 服务)把构建好的 CLI 当子进程拉起,参数是 app-server --stdio(packages/services/src/zcode-agent/zcodeAgentProcessManager.ts:369),此后双方只在这个子进程的标准输入输出上交谈。根 AGENTS.md 的“进程、协议与远程控制”一节把这条边界定成规则:Desktop 经 stdio 与 Agent 通信;Main 与外部 relay 不保存任务队列、快照等业务状态;已受理的 busy/running 输入由 CLI 的 CommandInbox 串行 admission,Renderer 只留未提交的草稿与乐观显示(AGENTS.md:59、AGENTS.md:64、AGENTS.md:65)。
代码分三处。Agent 一侧的服务端在 apps/zcode-cli/packages/bootstrap/src/:zcode-protocol/ 48 个文件、约 1.5 万行,放传输、服务器、旧方法与交互 broker;zcode-protocol-v4/ 52 个文件、约 2.2 万行,放 V4 网关、命令与投影;入口是 zcode-protocol-entrypoint.ts。协议的 schema 与纯函数在两边共同依赖的 packages/shared/src/:旧协议的 zcode-protocol/index.ts(3717 行)与 V4 的 zcode-protocol-v4/(46 个文件、约 8900 行)。Host 一侧的协议客户端、传输抽象(stdio、websocket、memory 三种,packages/services/src/zcode-agent/zcodeProtocolTransport.ts:4)与 stdio 实现也在 packages/services/src/zcode-agent/,归桌面应用一篇。
packages/client 名字像协议客户端,根 AGENTS.md 也写它是“Agent 客户端 SDK”(AGENTS.md:33),代码却是界面经 MessagePort 或 WebSocket 访问 Host 服务的 RPC 代理(packages/client/src/remoteServiceAccess.ts:50),与这条协议无关,见 Web 与服务端。
app-server 与 agent-server
两个子命令在代码里完全等价:run 里是同一个分支(apps/zcode-cli/packages/cli/src/run.ts:525),入口判断“这是协议进程”时两个名字一起认(apps/zcode-cli/packages/cli/src/arguments.ts:109),模型请求头里的来源也都记成 electron(apps/zcode-cli/packages/bootstrap/src/model-config.ts:75)。帮助文本只列了 app-server(apps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:18),agent-server 是没写进文档的别名。Host 总带着 --stdio,解析器也登记了这个布尔项(arguments.ts:78),但没有代码读它,协议服务端只有标准输入输出这一种传输。
| 参数 | 作用 |
|---|---|
--surface terminal、--surface desktop | 呈现面,缺省 terminal,desktop 映射为 zcode_desktop(run.ts:146)。后者让系统提示词多一段 ZCode Desktop Context,要求文件与本地 URL 写成 Markdown 链接、行内评审用 ::code-comment 指令(apps/zcode-cli/packages/core/src/context/builder.ts:131)。Host 只在本地桌面或桌面挂接的远程工作区里追加它(packages/services/src/zcode-agent/zcodeAgentPresentationSurface.ts:17) |
--prepare-storage | 存储准备模式:先把库路径报给 Host,等它回 startup/storagePathReady(最多 30 秒)再迁移会话库,完成后回 startup/storagePrepared 并退出,不起 Provider、MCP 与会话(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-entrypoint.ts:85、apps/zcode-cli/packages/bootstrap/src/zcode-protocol/storage-startup.ts:60) |
--cwd | 工作区目录;Host 一般直接把子进程的 cwd 设成工作区 |
协议进程只在开发态读工作区的 .env(apps/zcode-cli/packages/cli/src/env.ts:110),注释说打包态的 app-server 是桌面 Host 的内部子进程,读 .env 出错会让它在协议建立前退出,外层只看到一句 transport closed(run.ts:243)。runZCodeProtocolAgent 的启动顺序是:先开会话库,再起进程级 Provider Registry、遥测与 MCP 连接池,然后构造 ZCodeProtocolAgentServer,最后启动 NDJSON 连接并一直等到它关闭(zcode-protocol-entrypoint.ts:139、zcode-protocol-entrypoint.ts:249、zcode-protocol-entrypoint.ts:334、zcode-protocol-entrypoint.ts:346)。会话按需创建,每个会话一个 ZCodeApp,装配见bootstrap:把运行时拼起来。
帧:一行一个 JSON
消息形状沿用 JSON-RPC,但没有 jsonrpc 字段:请求是 id、method、params,通知只有 method、params,响应是 id、result,错误是 id 加 error(code、message、data)。四种都是 zod 的 strict 对象,还可以带一个 trace(traceparent、traceId、parentId、spanId),把调用链接到 Host(packages/shared/src/zcode-protocol/index.ts:275、zcode-protocol/index.ts:326)。请求 id 可以是字符串或整数(zcode-protocol/index.ts:272),Agent 反向发给 Host 的请求用 server-N(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server.ts:855)。
分帧只认换行:ZCodeProtocolNdjsonConnection 攒字节、按 \n 切行,逐行 JSON.parse 再过 schema(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/transport.ts:83、zcode-protocol/transport.ts:198)。Host 那边同样只认 LF,注释说 Node 的 readline 会把 U+2028、U+2029 当换行,模型文本里带这两个字符就会把一行合法 JSON 切成半帧(packages/services/src/zcode-agent/zcodeStdioTransport.ts:61)。
请求按到达顺序串行处理,排进一条 Promise 链;有两类例外。一是 Host 对 Agent 反向请求的响应,立刻处理,否则“当前请求等响应、响应等后续请求”会死锁(zcode-protocol/transport.ts:162);二是 session/stop 与 workspace/cancelGenerateText,它们只等前一条请求“开始执行”,然后越过它(zcode-protocol/transport.ts:224、zcode-protocol/transport.ts:171):
if (this.shouldBypassProcessingQueue(message)) {
// 停止/取消控制必须等它前面的普通请求真正进入 handler、建立 abort
// controller,再越过该请求的异步执行;只延后一轮微任务会让控制请求提前成为空操作。
void this.lastQueuedMessageStarted
.then(() => this.handleMessage(message))
.catch((error: unknown) => {
this.fail(error instanceof Error ? error : new Error(String(error)));
});
return;
}
let markStarted!: () => void;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
this.lastQueuedMessageStarted = started;
this.processing = this.processing
.then(async () => {
const handling = this.handleMessage(message);
markStarted();
await handling;
})
.catch((error: unknown) => {
markStarted();
this.fail(error instanceof Error ? error : new Error(String(error)));
});有的响应后面必须紧跟通知,例如订阅的响应只有 ACK,初始快照帧先登记在该请求 id 名下,写完响应行立即接着写(server.ts:479、zcode-protocol/transport.ts:243),不借助定时器,保证客户端一定先看到 ACK。stdin 结束后还留 100 毫秒把已收到的短请求处理完(zcode-protocol/transport.ts:22)。错误码:
| 代码 | 含义 | 出处 |
|---|---|---|
| -32700 | JSON 解析失败,响应 id 固定为 parse-error | zcode-protocol/transport.ts:209 |
| -32600 | 不符合消息 schema,id 为 invalid-message,附带 zod issues | zcode-protocol/transport.ts:215 |
| -32601 | 方法不存在 | server.ts:717 |
| -32603 | 其他异常,业务错误码放在 data.code | apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-types.ts:239 |
| -32004 | 会话不可用 | zcode-protocol/index.ts:79 |
| -32020、-32021、-32022 | Agent 发起的反向请求:没有客户端、被取消、超时 | server.ts:815 |
V4 的下行数据另有一层物理封装。每帧作为一条 v4/conversation/frame 通知发出,带 wireVersion: 3 与 deliveryKind(initial、online、recovery),要么是完整帧,要么是按 UTF-8 字节切出的分片,分片带 crc32 校验与 base64 数据(packages/shared/src/zcode-protocol-v4/wire.ts:16、wire.ts:19)。单个物理帧不超过 1 MiB,重组后的逻辑帧不超过 16 MiB、最多 1024 片、30 秒内要收齐(packages/shared/src/zcode-protocol-v4/core.ts:64)。编码器按三种载体分别计量一帧的大小:CLI 的 NDJSON 行、Host 的 Channel socket、手机 relay 的 base64 信封,取最大值(packages/shared/src/zcode-protocol-v4/wire-codec.ts:25);这个文件在 third-party/copied-components.json:224 里登记为源自 VS Code 的 IPC 代码。
stdout 必须干净
stdout 是帧通道,混进任何一行非 JSON 的文本,Host 就会解析失败(apps/zcode-cli/packages/cli/src/main.ts:30)。CLI 入口因此在加载 run 与 bootstrap 之前先装好几道护栏:
- 全局
console整体改写到 stderr(main.ts:34、apps/zcode-cli/packages/cli/src/protocol-console.ts:10)。 - AI SDK 的警告默认第一次会用
console.info写 stdout,协议入口把它的 warning logger 换成写日志文件(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/ai-sdk-warning-logger.ts:16)。 - stderr 也有边界:监听真实流的
error与close,一旦失效就只完成回调、不再写,注释说否则 EPIPE 会引发 uncaughtException、再写 stderr、再 EPIPE,形成高 CPU 的自激循环(apps/zcode-cli/packages/cli/src/protocol-stderr.ts:1)。 - 最后一道进程级异常边界:留一次诊断,然后交给生命周期有界关闭,不带病继续接单(
apps/zcode-cli/packages/cli/src/process-errors.ts:29)。 - 生命周期是退出的唯一所有者:stdin 结束后等 100 毫秒再中止,硬截止 1.5 秒;SIGINT、SIGTERM、SIGHUP 分别以 130、143、129 退出(
apps/zcode-cli/packages/cli/src/protocol-lifecycle.ts:4、protocol-lifecycle.ts:31)。
TUI 也用了第一道护栏,理由相同,见上一篇的最后一节;入口的全貌见命令行入口、无头模式与打包。
握手与版本
Host 与 Agent 之间没有一次性的 hello。子进程起来后最先出现在 stdout 上的,是连接建立之前直接写出的存储启动帧 startup/storageState,阶段依次是 checking、waiting_for_lock、migrating、committing,最终 ready 或 failed(packages/shared/src/zcode-protocol/index.ts:345);每帧都等底层流确认写出后才继续执行 SQL(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/storage-startup.ts:31),Host 的存储闸门在它 ready 之前挡住所有请求(packages/services/src/zcode-agent/zcodeProtocolClient.ts:142)。能力探测是一次普通请求 runtime/capabilities,目前只回 independentPlanState: true(server.ts:678)。
版本靠几个常量和一组约定维持。旧主协议的 ZCODE_PROTOCOL_VERSION 是 1,V4 的物理 wire 版本是 3,注释写着“V4 wire 与 legacy 主协议并存;禁止为了 V4 physical framing 改写 legacy 版本”(zcode-protocol/index.ts:73、zcode-protocol/index.ts:74);V4 快照自带 protocolVersion: 1(packages/shared/src/zcode-protocol-v4/snapshot.ts:470)。新增字段一律可选;新方法“天然偏斜安全”,因为旧桌面根本不会调用(packages/shared/src/zcode-protocol-v4/transport.ts:323);反过来旧 CLI 不认识的方法回 -32601,由 Host 降级忽略(zcode-protocol/index.ts:3604)。
V4 规范里确实有 hello 与 clientHello,但它们发生在界面与 Host 之间:Host 的连接门面发出 hello,里面有连接 id、clientMode、必须与之匹配的 deliveryProfile、服务端时钟与能力;界面回 clientHello 报上自己的 clientId(packages/shared/src/zcode-protocol-v4/transport.ts:35、zcode-protocol-v4/transport.ts:62,发出方在 packages/services/src/zcode-agent/zcodeAgentConnectionScope.ts:649)。此后 Host 转发给 Agent 的请求,会先删掉界面自带的 connectionId、clientMode、deliveryProfile,再写入自己的可信值(zcodeAgentConnectionScope.ts:82);Agent 也只认这份注入的 clientMode(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/v4-gateway.ts:1396)。packages/shared/src/handshake.ts:1 里的 zcode-hello 则是另一回事,是远程服务端经 stdio 起来时的握手(packages/server/src/remote/handshake.ts:15),见远程工作区与手机远控。
旧协议与 V4 共用一条管道。V4 方法一律带 v4/ 前缀(zcode-protocol-v4/transport.ts:305),session/* 等旧方法仍在分派(server.ts:569),方法表里逐条标着 @deprecated(zcode-protocol/index.ts:3573),旧协议仍在用的承重类型已迁到 zcode-protocol-legacy-types.ts,文件头称之为为删除旧协议树铺路的“re-home 迁移产物”(packages/shared/src/zcode-protocol-legacy-types.ts:2)。运行时事件无条件喂给 V4 网关,只有存在旧订阅者时才另发一份 session/event(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3044、server-operations.ts:3045)。根 AGENTS.md 要求“协议改动同步更新 packages/shared/src/zcode-protocol/index.ts”(AGENTS.md:59),而 V4 的 schema 实际都在 zcode-protocol-v4/ 下,旧文件自己也说 V4 主链已不再依赖旧版全量方法表(zcode-protocol/index.ts:3670)。
一次往返
方法、命令与通知
V4 的方法(zcode-protocol-v4/transport.ts:307):
| 类别 | 方法 |
|---|---|
| 订阅 | v4/conversation/subscribe、resync、unsubscribe;同一组方法按 topic 前缀服务 conversation/ 会话、sessions-index/ 会话列表、workspace-config/ 配置目录三种 topic(server.ts:465) |
| 流控 | v4/connection/flow,状态为 saturated、drained、closed |
| 命令 | v4/command、v4/commands/query |
| 只读查询 | rowsRange、plans、fileChanges、fileRewindPreview、backgroundBashOutput,7 个工作流运行查询,v4/usage/stats 与 v4/conversation/usage |
| 附件 | v4/attachment/ 下的 begin、chunk、commit、abort、read、previewSource,以及会话内的 attachmentRead、attachmentStat;单块解码后不超过 512 KiB |
| 下行通知 | v4/conversation/frame,以及两种遥测事实与 Computer Use 权限观察(zcode-protocol-v4/transport.ts:382) |
v4/controller/* 也在表里,但 Agent 不处理它,那两个 topic 由桌面 Host 自己投影(packages/desktop/src/host/windowHostControllerProjection.ts:200)。反向请求是 Agent 发给 Host 的:interaction/requestPermission、interaction/requestUserInput、Provider 与官方 MCP 的鉴权头、浏览器控制的 browserList 与 browserExecute、session/requestRuntimePreferences,还有 automation/* 与 offPeak/*,定时与闲时任务的定义归桌面端管(见定时任务与闲时任务)。其余通知有资源采样、MCP 遥测、插件操作进度与 Computer Use 生命周期(zcode-protocol/index.ts:334)。
命令一共 34 种,全部有原生 handler,按分组注册(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/index.ts:15):
| 类别 | 命令 |
|---|---|
| 会话 | createSession、createSelectionSideSession、renameSession、deleteSession、discardSharedContext |
| 输入与执行 | sendText、sendGoalCommand、compact、stop |
| 队列 | sendQueuedNow、editQueueItem、reorderQueueItem、deleteQueueItem、setAutoDrain、setFollowupMode |
| 分支与回退 | forkAssistant、editUserQuery、retryTurn、applyFileRewind |
| 配置与目标 | switchModelConfig、switchCollaborationMode、pauseGoal、resumeGoal |
| 交互应答 | resolveInteraction、snoozeInteractionAutoResolution、setAssistantFeedback |
| 工作区钩子审核 | respondWorkspaceHookReview、toggleWorkspaceHookReviewItem、revokeWorkspaceHookTrust、requestWorkspaceHookReview |
| 后台与工作流 | cancelBackgroundWork、resumeWorkflowRun、startSavedWorkflow、amendWorkflowRunSettings |
v4-bridge 的注释两处说“20 命令全部原生”(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/v4-bridge.ts:6、v4-bridge.ts:1573),数字已经过时,payload 表里是 34 种(packages/shared/src/zcode-protocol-v4/command.ts:43)。选区侧聊会话不接受目标、编辑、重试、分叉等 7 种命令(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/executor.ts:10)。所有命令共用一个信封(command.ts:320):
export const commandEnvelopeSchema = z.object({
ttft: localTtftContextSchema.optional(),
// uuid v7,客户端生成,重试不变。
commandId: z.string(),
clientId: z.string(),
// createSession 时为 null。
sessionId: z.string().nullable(),
baseRevision: z.number().optional(),
baseLogEpoch: z.string().trim().min(1).optional(),
type: commandTypeSchema,
payload: z.unknown(),
// 客户端时钟,仅遥测;服务端不用于任何裁决。
issuedAt: timestampSchema,
});ACK 的状态有六种:accepted、rejected、stale、duplicate、noop、failed,其中 rejected、stale、noop、failed 必带 reasonCode,如 proto.staleRevision、guard.stopTargetChanged(command.ts:433)。注释说 accepted“不承诺跨 CLI 进程存活”,最终以持久化的事实为准(command.ts:432);v4/commands/query 一次可以按键查 1 到 64 条命令的结果(command.ts:453)。
CommandInbox:串行受理与幂等
v4/command 先进 CommandInbox(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/command-inbox.ts:107),流程如下:
两道锁的顺序是固定的:先按“会话加命令 id”拿 key gate,再拿会话级 gate,而会话级 gate 一直持有到命令 settle,所以同一会话的不同命令按 CLI 实际受理的顺序串行(command-inbox.ts:139):
// 固定锁序:key gate → per-session admission gate。session gate 持有到 settle,
// 因而同 session 不同 commandId 以 CLI 实际执行 admission 的顺序串行。
const releaseSession = await this.sessionGates.acquire(bucketKey);
try {
// 等待 session gate 期间,上一条命令可能增量写入了本 key 的持久化事实。
// ...
const decision = this.decide(envelope);
if (decision.kind === "ack") {
if (decision.remember) this.rememberSettled(bucketKey, envelope.commandId, decision.ack);
releaseSession();
return this.ackOnly(decision.ack);
}幂等靠 commandId。查找顺序是在途命令(等它的终态)、已 pin 的 live 输入、每会话 512 条的 settled LRU,最后依次问持久化的对话、时间线、子会话与丢弃记录(command-inbox.ts:284、packages/shared/src/zcode-protocol-v4/core.ts:82)。在途与 live 输入永远 pin 住、不进 LRU,注释说旧的单表 LRU 在超过 512 条时会淘汰仍在执行的命令,查询返回 unknown,客户端一重试就执行第二遍(command-inbox.ts:172)。createSession 这类没有会话的命令归一个全局桶(command-inbox.ts:72)。
decide 做四项检查(command-inbox.ts:308):会话必须存在;15 种命令要求带 baseRevision 做 CAS,其中 5 种针对具体某一行的还要带 baseLogEpoch(command.ts:293、command.ts:311),epoch 或 revision 对不上就回 stale;然后是行目标校验与业务 guard。revision 只在行结构或 A 区状态变化时加一,流式文本增量、用量、待处理命令与工作流运行态不算,免得工作流在飞时频繁打翻 CAS(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/projection-state.ts:190)。
这里只管“命令受理的次序与幂等”,一条输入是立即开跑、进队列还是引导进当前回合,由 core 决定。sendText 做完协议层的校验,把输入交给 app.sendInput,交付方式是 start_turn,路由为 guide 时再附 queueDelivery: "guide"(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/prompt-turn.ts:103、prompt-turn.ts:113),core 忙就返回 queued,ACK 里带上实际的 delivery(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/session-flow.ts:184)。快照里的 inputRouting 告诉界面此刻一条新输入会被怎样处理,裁决表注明与 packages/formal-proof 的模型逐条对齐(projection-state.ts:115、projection-state.ts:157):
export function computeInputRouting(
context: AvailabilityContext,
followupMode: "queue" | "guide",
): InputRouting {
// formal-proof: compactingAcceptsFutureInput
// —— compact 是维护步骤,输入是未来意图 → 入队,不打断 compact。
if (context.compacting) {
return { mode: "enqueue", reasonCode: "compactingAcceptsFutureInput" };
}
// goal verifier 是 completion-blocking active work,但不是普通
// assistant active turn;只看 phase=running 会在 guide 模式下尝试 steer,
// core 此时没有 steerable activeTurn,导致用户输入既不进 queue 也不进历史。
if (context.goalVerifying) {
return { mode: "enqueue", reasonCode: "goalVerifierAcceptsFutureInput" };
}
if (context.phase === "running" || context.phase === "prewarming") {
return { mode: followupMode === "guide" ? "guide" : "enqueue" };
}
// completed + queue>0 + autoDrain=false 时,输入不静默入队;
// 客户端呈现 clear/keep 选择,disposition 随 command 上行。
const completed =
context.phase === "completedSuccess" || context.phase === "completedInterrupted";
if (completed && context.queueLength > 0 && !context.autoDrain) {
return { mode: "choice", reasonCode: "heldQueueInputRequiresChoice" };
}
return { mode: "startNow" };最后一种 choice 就是“暂停的队列”:界面让用户选清空队列再发还是保留队列立即发,选择随 heldQueueDisposition 上行,CLI 还会核对用户确认时看到的队列条目,防止多端并发增删(command.ts:91)。core 一侧的受理、排队与引导见输入受理、命令队列与引导,形式化模型见怎么读这份源码。
快照、增量与重连
每个会话有一个 ConversationTopicPublisher,它是 CLI 侧的权威记账:内存里一份有界的 delta 日志,外加每个订阅者的 flush 管线“过滤、合并、打帧”(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/conversation-topic-publisher.ts:1)。运行时事件先由 ProductProjection 投影成两类数据:A 区的会话状态(控制、可用操作、输入路由、配置、用量、队列、待处理交互、后台工作、子 Agent、工作流运行、目标、计划等)与 B 区的行窗口(snapshot.ts:469)。行有 9 种:回合头、用户输入、助手文本、推理、工具调用、产物、子 Agent、钩子调用、时间线标记(packages/shared/src/zcode-protocol-v4/rows.ts:418)。增量只有五种操作:追加行、按 rowId 整行替换、删除某行及其后所有行、流式文本追加、状态键级整体替换;注释说,凡是这五种表达不了的变化,服务端一律发快照重同步(packages/shared/src/zcode-protocol-v4/delta.ts:1、delta.ts:53)。
每个帧标着 (fromSeq, toSeq],快照帧的 fromSeq 固定为 0,客户端据此检查连续性(zcode-protocol-v4/transport.ts:134)。订阅时客户端可以带上自己确实持有的水位 base(logEpoch 与 seq,zcode-protocol-v4/transport.ts:84):epoch 相同、seq 还在保留窗内,就回 resume,只重放 (base.seq, 当前] 的增量,与在线续流走同一条过滤合并管线;否则回 snapshot(conversation-topic-publisher.ts:744)。保留窗是每会话 2000 条事件(core.ts:74),subscriptionId 形如 sub-<logEpoch>-<序号>,同一连接重复订阅即替换旧订阅,旧代的帧由客户端丢弃(conversation-topic-publisher.ts:712)。
还有三条恢复路径:
- 订阅者积压:单个订阅者的待发缓冲超过 500 个操作或 1 MiB,就清空缓冲、标记需要重同步,下一次 flush 直接发一帧快照(
conversation-topic-publisher.ts:535、conversation-topic-publisher.ts:824,上限在core.ts:72)。 - 同订阅恢复:
v4/conversation/resync保持 subscriptionId 与档位不变,从客户端给的base重新裁决,可以强制要快照,帧标为recovery(conversation-topic-publisher.ts:873)。只有 topic、subscriptionId、connectionId 三者都对上的订阅才能恢复,否则回fault.subscription.notOwned(v4-gateway.ts:1471)。 - 冷恢复:重启后首次订阅一个不在内存里的会话,先从会话库恢复记录,再用持久化消息合成事件、与库里的事件合并,重放进投影;期间到达的实时事件先缓冲、按事件 id 去重(
v4-gateway.ts:2881)。同一会话的命令会等这次恢复完成再进 inbox(v4-gateway.ts:2374)。
冷恢复用到的三个纯函数(synthesizeEventsFromMessages、mergeColdConversationEvents、ProductProjection)另由 bootstrap 的 ./v4-replay 子路径导出,注释说供下游在浏览器里做回放页面,并由浏览器包的构建检查它不混进 Node 内建模块(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/replay.ts:1、apps/zcode-cli/packages/bootstrap/package.json:13);本仓库里没有找到它的使用方。持久化本身见 SQLite 会话库。
desktop-continuous 与 web-remote-replayable
clientMode 只有这两个值。本地桌面的 Renderer 是 desktop-continuous;Web 服务端普通的 /ws 连接一律是 web-remote-replayable,只有带着 Host 签发的能力凭据连上 /ws/host 的才是 desktop-continuous(packages/server/src/http.ts:320);手机远控挂接到桌面已有的 Host,走的是 web-remote-replayable(AGENTS.md:62、AGENTS.md:63)。Agent 按它选投递档位(core.ts:34):
export const DELIVERY_PROFILES = {
continuous: {
desktopOnlyRows: true,
flushWindowMs: 30,
streamPaths: {
text: true,
inputText: true,
"output.text": true,
summaryText: true,
},
streamOutputCapBytes: 262144,
toolProgress: false,
},
replayable: {
desktopOnlyRows: false,
flushWindowMs: 150,
streamPaths: {
text: true,
inputText: false,
"output.text": false,
summaryText: false,
},
streamOutputCapBytes: 0,
toolProgress: true,
},
} as const satisfies Record<string, DeliveryProfile>;真正起作用的是两项。flushWindowMs 决定网关为每个订阅者攒多久再打一帧:桌面 30 毫秒,可恢复链路 150 毫秒(v4-gateway.ts:3285)。streamPaths 决定哪些流式增量下发:桌面四条路径都流,可恢复链路只流助手正文,工具输入、工具输出与摘要都等定稿的整行替换(packages/shared/src/zcode-protocol-v4/profiles.ts:27)。另外三项 desktopOnlyRows、streamOutputCapBytes、toolProgress 目前没有任何读取方,行过滤函数也原样返回全部行(profiles.ts:13)。过滤有一条不变量:被过滤掉的增量必须由之后某个不可过滤的事件收口,两种档位的终态要逐字节一致,由黄金测试守着(profiles.ts:5)。所以两种语义的差别在“过程”:桌面看到细粒度的实时流,手机与 Web 看到更稀、更适合断线后重放的帧,最后的会话状态相同。
交互请求:两条应答路径竞速
需要问人时,协议侧的 broker 按工具分三路:AskUserQuestion 走用户输入请求,ExitPlanMode 走计划审批,其余走权限请求(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-broker.ts:43)。每一路都同时挂两个出口:一个反向 RPC 发给 Host,一个在 V4 投影里出现为 pendingInteractions,谁先应答算谁,V4 的 resolveInteraction 先到就取消悬空的反向请求(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-response-race.ts:20)。多个客户端先到先得,晚到的应答按幂等成功收口,不报错(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/interaction-background.ts:45)。等待期间反向请求会以同一个业务 id 重发,好让只从快照恢复出界面的 Host 重新登记:首次间隔 1 秒(interaction-broker.ts:41),之后每次翻倍,封顶 10 秒(server.ts:121、server.ts:882)。审批选项与规则持久化见权限模式与规则,问卷的自动解决见 Todo、提问与 Plan 模式。
stale 防护与 owner/lease
根 AGENTS.md 要求保留 owner/lease、跨 Host 路由和 stale run 防护,不能只看单一路径就删掉边界判断(AGENTS.md:66)。Agent 这一侧的防护有这些:
| 防什么 | 怎么做 |
|---|---|
| 界面基于过期状态下的命令 | baseRevision 与 baseLogEpoch 的 CAS,对不上回 stale(command-inbox.ts:325) |
| 迟到的 Stop 误杀下一轮 | stop 带上界面看到的前台执行 id,core 发现已换人就返回 mismatch,协议回 noop guard.stopTargetChanged(session-flow.ts:333、apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-queue.ts:455) |
| 旧订阅的帧串进新订阅 | subscriptionId 带 epoch 与序号,重订阅即替换;恢复与退订按 topic、订阅 id、连接 id 精确匹配(conversation-topic-publisher.ts:712、v4-gateway.ts:1471) |
| “立即发送”与排队抢同一个空闲位 | 先拿唯一的前台晋升租约、抢占当前回合,再以 requireIdle 启动(session-flow.ts:211) |
| 在只读的子 Agent 会话里发输入 | 受理前按会话类型拒绝,guard.subagentReadOnly(v4-bridge.ts:1594) |
Host 一侧按连接登记订阅的所有权,决定帧该路由给谁、哪些退订请求可以转发,见桌面应用与远程工作区与手机远控。
会话驻留
一个 app-server 进程可以同时托管多个会话,每个会话一份 ZCodeApp。SessionResidentPool 控制有多少留在内存里(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/session-resident-pool.ts:7):
| 参数 | 默认值 |
|---|---|
| 目标常驻数 | 8 |
| 高水位 | 16 |
| 空闲超时 | 10 分钟 |
每个协议请求在处理期间持有一份进程级租约,涉及的会话另计一份(server.ts:444、session-resident-pool.ts:97)。可以回收的会话必须同时满足:已经持久化;没有阻止驻留的工作,既包括协议层正在收尾的 runner,也包括 runtime 自报的在途或排队回合、运行中的后台任务、标题生成与 MCP 启动这类脱离调用栈的工作、待做的记忆抽取(apps/zcode-cli/packages/core/src/runtime/methods/residency.ts:21);没有待处理交互、排队命令与订阅者;也没有请求持有它的租约(session-resident-pool.ts:226,事实来自 apps/zcode-cli/packages/bootstrap/src/zcode-protocol/session-residency.ts:76)。收敛在两个时机进行:每个请求释放租约时,以及 60 秒一次的资源采样(session-resident-pool.ts:148、apps/zcode-cli/packages/bootstrap/src/zcode-protocol/resource-sampler.ts:32,间隔见 packages/shared/src/processResourceTelemetry.ts:29)。先回收空闲超过 10 分钟的,再在超过高水位时按最近使用时间回收到目标数。去激活只释放内存里的运行时,不删持久事实:先取消订阅、从投影与注册表摘除,再关 App、清掉内存事件存储,使它与“从未加载”等价(session-residency.ts:53)。runtime 一侧的驻留事实见会话事件流与持久化投影。
下一篇:桌面应用:Electron 的三层——协议的另一端:Main、Host 与 Renderer 怎样分工,Host 怎样拉起并管理这个 Agent 子进程。