ZCode Protocol V4:Agent 对外的线协议

桌面端与 Web 服务端怎样经 stdio 驱动 Agent 子进程:NDJSON 帧与 stdout 保护,版本与握手,V4 方法与 34 种命令,CommandInbox 的串行受理与幂等,快照续传,两种投递档位,交互应答竞速,stale 防护与会话驻留。

作者 David更新于 43 篇(共 47 篇)

上一篇的终端界面与运行时同进程。桌面端与 Web 服务端不这样:它们的 Host(packages/services 里的 Agent 服务)把构建好的 CLI 当子进程拉起,参数是 app-server --stdiopackages/services/src/zcode-agent/zcodeAgentProcessManager.ts:369),此后双方只在这个子进程的标准输入输出上交谈。根 AGENTS.md 的“进程、协议与远程控制”一节把这条边界定成规则:Desktop 经 stdio 与 Agent 通信;Main 与外部 relay 不保存任务队列、快照等业务状态;已受理的 busy/running 输入由 CLI 的 CommandInbox 串行 admission,Renderer 只留未提交的草稿与乐观显示(AGENTS.md:59AGENTS.md:64AGENTS.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),模型请求头里的来源也都记成 electronapps/zcode-cli/packages/bootstrap/src/model-config.ts:75)。帮助文本只列了 app-serverapps/zcode-cli/packages/i18n/src/locales/zh-CN.ts:18),agent-server 是没写进文档的别名。Host 总带着 --stdio,解析器也登记了这个布尔项(arguments.ts:78),但没有代码读它,协议服务端只有标准输入输出这一种传输。

参数作用
--surface terminal--surface desktop呈现面,缺省 terminaldesktop 映射为 zcode_desktoprun.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:85apps/zcode-cli/packages/bootstrap/src/zcode-protocol/storage-startup.ts:60
--cwd工作区目录;Host 一般直接把子进程的 cwd 设成工作区

协议进程只在开发态读工作区的 .envapps/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:139zcode-protocol-entrypoint.ts:249zcode-protocol-entrypoint.ts:334zcode-protocol-entrypoint.ts:346)。会话按需创建,每个会话一个 ZCodeApp,装配见bootstrap:把运行时拼起来

帧:一行一个 JSON

消息形状沿用 JSON-RPC,但没有 jsonrpc 字段:请求是 idmethodparams,通知只有 methodparams,响应是 idresult,错误是 iderrorcodemessagedata)。四种都是 zod 的 strict 对象,还可以带一个 tracetraceparenttraceIdparentIdspanId),把调用链接到 Host(packages/shared/src/zcode-protocol/index.ts:275zcode-protocol/index.ts:326)。请求 id 可以是字符串或整数(zcode-protocol/index.ts:272),Agent 反向发给 Host 的请求用 server-Napps/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:83zcode-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/stopworkspace/cancelGenerateText,它们只等前一条请求“开始执行”,然后越过它(zcode-protocol/transport.ts:224zcode-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:479zcode-protocol/transport.ts:243),不借助定时器,保证客户端一定先看到 ACK。stdin 结束后还留 100 毫秒把已收到的短请求处理完(zcode-protocol/transport.ts:22)。错误码:

代码含义出处
-32700JSON 解析失败,响应 id 固定为 parse-errorzcode-protocol/transport.ts:209
-32600不符合消息 schema,id 为 invalid-message,附带 zod issueszcode-protocol/transport.ts:215
-32601方法不存在server.ts:717
-32603其他异常,业务错误码放在 data.codeapps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-types.ts:239
-32004会话不可用zcode-protocol/index.ts:79
-32020、-32021、-32022Agent 发起的反向请求:没有客户端、被取消、超时server.ts:815

V4 的下行数据另有一层物理封装。每帧作为一条 v4/conversation/frame 通知发出,带 wireVersion: 3deliveryKindinitialonlinerecovery),要么是完整帧,要么是按 UTF-8 字节切出的分片,分片带 crc32 校验与 base64 数据(packages/shared/src/zcode-protocol-v4/wire.ts:16wire.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:34apps/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 也有边界:监听真实流的 errorclose,一旦失效就只完成回调、不再写,注释说否则 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:4protocol-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: trueserver.ts:678)。

版本靠几个常量和一组约定维持。旧主协议的 ZCODE_PROTOCOL_VERSION 是 1,V4 的物理 wire 版本是 3,注释写着“V4 wire 与 legacy 主协议并存;禁止为了 V4 physical framing 改写 legacy 版本”(zcode-protocol/index.ts:73zcode-protocol/index.ts:74);V4 快照自带 protocolVersion: 1packages/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 规范里确实有 helloclientHello,但它们发生在界面与 Host 之间:Host 的连接门面发出 hello,里面有连接 id、clientMode、必须与之匹配的 deliveryProfile、服务端时钟与能力;界面回 clientHello 报上自己的 clientIdpackages/shared/src/zcode-protocol-v4/transport.ts:35zcode-protocol-v4/transport.ts:62,发出方在 packages/services/src/zcode-agent/zcodeAgentConnectionScope.ts:649)。此后 Host 转发给 Agent 的请求,会先删掉界面自带的 connectionIdclientModedeliveryProfile,再写入自己的可信值(zcodeAgentConnectionScope.ts:82);Agent 也只认这份注入的 clientModeapps/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),方法表里逐条标着 @deprecatedzcode-protocol/index.ts:3573),旧协议仍在用的承重类型已迁到 zcode-protocol-legacy-types.ts,文件头称之为为删除旧协议树铺路的“re-home 迁移产物”(packages/shared/src/zcode-protocol-legacy-types.ts:2)。运行时事件无条件喂给 V4 网关,只有存在旧订阅者时才另发一份 session/eventapps/zcode-cli/packages/bootstrap/src/zcode-protocol/server-operations.ts:3044server-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/subscriberesyncunsubscribe;同一组方法按 topic 前缀服务 conversation/ 会话、sessions-index/ 会话列表、workspace-config/ 配置目录三种 topic(server.ts:465
流控v4/connection/flow,状态为 saturated、drained、closed
命令v4/commandv4/commands/query
只读查询rowsRangeplansfileChangesfileRewindPreviewbackgroundBashOutput,7 个工作流运行查询,v4/usage/statsv4/conversation/usage
附件v4/attachment/ 下的 begin、chunk、commit、abort、read、previewSource,以及会话内的 attachmentReadattachmentStat;单块解码后不超过 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/requestPermissioninteraction/requestUserInput、Provider 与官方 MCP 的鉴权头、浏览器控制的 browserListbrowserExecutesession/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):

类别命令
会话createSessioncreateSelectionSideSessionrenameSessiondeleteSessiondiscardSharedContext
输入与执行sendTextsendGoalCommandcompactstop
队列sendQueuedNoweditQueueItemreorderQueueItemdeleteQueueItemsetAutoDrainsetFollowupMode
分支与回退forkAssistanteditUserQueryretryTurnapplyFileRewind
配置与目标switchModelConfigswitchCollaborationModepauseGoalresumeGoal
交互应答resolveInteractionsnoozeInteractionAutoResolutionsetAssistantFeedback
工作区钩子审核respondWorkspaceHookReviewtoggleWorkspaceHookReviewItemrevokeWorkspaceHookTrustrequestWorkspaceHookReview
后台与工作流cancelBackgroundWorkresumeWorkflowRunstartSavedWorkflowamendWorkflowRunSettings

v4-bridge 的注释两处说“20 命令全部原生”(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/v4-bridge.ts:6v4-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.staleRevisionguard.stopTargetChangedcommand.ts:433)。注释说 accepted“不承诺跨 CLI 进程存活”,最终以持久化的事实为准(command.ts:432);v4/commands/query 一次可以按键查 1 到 64 条命令的结果(command.ts:453)。

CommandInbox:串行受理与幂等

v4/command 先进 CommandInboxapps/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:284packages/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 种针对具体某一行的还要带 baseLogEpochcommand.ts:293command.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:103prompt-turn.ts:113),core 忙就返回 queued,ACK 里带上实际的 deliveryapps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/handlers/session-flow.ts:184)。快照里的 inputRouting 告诉界面此刻一条新输入会被怎样处理,裁决表注明与 packages/formal-proof 的模型逐条对齐(projection-state.ts:115projection-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:1delta.ts:53)。

每个帧标着 (fromSeq, toSeq],快照帧的 fromSeq 固定为 0,客户端据此检查连续性(zcode-protocol-v4/transport.ts:134)。订阅时客户端可以带上自己确实持有的水位 baselogEpochseqzcode-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:535conversation-topic-publisher.ts:824,上限在 core.ts:72)。
  • 同订阅恢复v4/conversation/resync 保持 subscriptionId 与档位不变,从客户端给的 base 重新裁决,可以强制要快照,帧标为 recoveryconversation-topic-publisher.ts:873)。只有 topic、subscriptionId、connectionId 三者都对上的订阅才能恢复,否则回 fault.subscription.notOwnedv4-gateway.ts:1471)。
  • 冷恢复:重启后首次订阅一个不在内存里的会话,先从会话库恢复记录,再用持久化消息合成事件、与库里的事件合并,重放进投影;期间到达的实时事件先缓冲、按事件 id 去重(v4-gateway.ts:2881)。同一会话的命令会等这次恢复完成再进 inbox(v4-gateway.ts:2374)。

冷恢复用到的三个纯函数(synthesizeEventsFromMessagesmergeColdConversationEventsProductProjection)另由 bootstrap 的 ./v4-replay 子路径导出,注释说供下游在浏览器里做回放页面,并由浏览器包的构建检查它不混进 Node 内建模块(apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/replay.ts:1apps/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-continuouspackages/server/src/http.ts:320);手机远控挂接到桌面已有的 Host,走的是 web-remote-replayableAGENTS.md:62AGENTS.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)。另外三项 desktopOnlyRowsstreamOutputCapBytestoolProgress 目前没有任何读取方,行过滤函数也原样返回全部行(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:121server.ts:882)。审批选项与规则持久化见权限模式与规则,问卷的自动解决见 Todo、提问与 Plan 模式

stale 防护与 owner/lease

AGENTS.md 要求保留 owner/lease、跨 Host 路由和 stale run 防护,不能只看单一路径就删掉边界判断(AGENTS.md:66)。Agent 这一侧的防护有这些:

防什么怎么做
界面基于过期状态下的命令baseRevisionbaseLogEpoch 的 CAS,对不上回 stale(command-inbox.ts:325
迟到的 Stop 误杀下一轮stop 带上界面看到的前台执行 id,core 发现已换人就返回 mismatch,协议回 noop guard.stopTargetChangedsession-flow.ts:333apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-queue.ts:455
旧订阅的帧串进新订阅subscriptionId 带 epoch 与序号,重订阅即替换;恢复与退订按 topic、订阅 id、连接 id 精确匹配(conversation-topic-publisher.ts:712v4-gateway.ts:1471
“立即发送”与排队抢同一个空闲位先拿唯一的前台晋升租约、抢占当前回合,再以 requireIdle 启动(session-flow.ts:211
在只读的子 Agent 会话里发输入受理前按会话类型拒绝,guard.subagentReadOnlyv4-bridge.ts:1594

Host 一侧按连接登记订阅的所有权,决定帧该路由给谁、哪些退订请求可以转发,见桌面应用远程工作区与手机远控

会话驻留

一个 app-server 进程可以同时托管多个会话,每个会话一份 ZCodeAppSessionResidentPool 控制有多少留在内存里(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/session-resident-pool.ts:7):

参数默认值
目标常驻数8
高水位16
空闲超时10 分钟

每个协议请求在处理期间持有一份进程级租约,涉及的会话另计一份(server.ts:444session-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:148apps/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 子进程。

本页目录