# ZCode Protocol V4：Agent 对外的线协议

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

- 作者：David（道雾轩）
- 专栏：ZCode 源码解读（https://daiw.org/manual/zcode.md）
- 最后更新：2026-09-21
- 原文：https://daiw.org/manual/zcode/zcode-protocol
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

[上一篇](https://daiw.org/manual/zcode/tui)的终端界面与运行时同进程。桌面端与 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/`，归[桌面应用](https://daiw.org/manual/zcode/desktop)一篇。

`packages/client` 名字像协议客户端，根 `AGENTS.md` 也写它是“Agent 客户端 SDK”（`AGENTS.md:33`），代码却是界面经 MessagePort 或 WebSocket 访问 Host 服务的 RPC 代理（`packages/client/src/remoteServiceAccess.ts:50`），与这条协议无关，见 [Web 与服务端](https://daiw.org/manual/zcode/server-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：把运行时拼起来](https://daiw.org/manual/zcode/bootstrap-assembly)。

## 帧：一行一个 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`）：

```ts
    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 也用了第一道护栏，理由相同，见[上一篇](https://daiw.org/manual/zcode/tui)的最后一节；入口的全貌见[命令行入口、无头模式与打包](https://daiw.org/manual/zcode/cli-surface)。

## 握手与版本

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`），见[远程工作区与手机远控](https://daiw.org/manual/zcode/remote)。

旧协议与 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`）。

## 一次往返

```mermaid
sequenceDiagram
  participant U as 界面（Renderer、Web、手机）
  participant H as Host（services 的 Agent 服务）
  participant A as Agent（zcode app-server）
  H->>A: 拉起子进程 app-server --stdio
  A-->>H: startup/storageState 直到 ready
  U->>H: helloConversationV4
  H-->>U: hello（clientMode 与 deliveryProfile）
  U->>H: initializeConversationV4（clientHello）
  H->>A: v4/conversation/subscribe（topic、connectionId、clientMode、base）
  A-->>H: 响应 ack（subscriptionId、snapshot 或 resume、logEpoch）
  A-->>H: v4/conversation/frame（initial）
  U->>H: 发送一条消息
  H->>A: v4/command sendText（commandId）
  A-->>H: ACK accepted 与 inputAccepted
  loop 每 30 或 150 毫秒
    A-->>H: v4/conversation/frame（deltas）
  end
  A->>H: interaction/requestPermission（id 为 server-N）
  A-->>H: frame 里 pendingInteractions 更新
  U->>H: 用户点允许
  alt V4 客户端
    H->>A: v4/command resolveInteraction
  else 旧客户端
    H-->>A: 反向请求的 response
  end
  A-->>H: frame（工具结果、回合结束）
```

## 方法、命令与通知

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/*`，定时与闲时任务的定义归桌面端管（见[定时任务与闲时任务](https://daiw.org/manual/zcode/cron-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`）：

```ts
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`），流程如下：

```mermaid
flowchart TD
  IN["v4/command 信封"] --> P{"信封与 payload 校验"}
  P -->|"失败"| R1["rejected proto.invalidPayload"]
  P --> KG["key gate：sessionId + commandId"]
  KG --> L1{"在途、live、settled 或持久事实里有同一命令？"}
  L1 -->|"有"| DUP["回 duplicate，failed 保持 failed"]
  L1 -->|"没有"| SG["session gate：同会话 FIFO"]
  SG --> D{"decide：会话存在、CAS、行目标、业务 guard"}
  D -->|"stale、rejected 或 noop"| ACK["只回 ACK"]
  D -->|"allow"| EX["分配 admissionSeq，标为在途"]
  EX --> RUN["executeCommand 调原生 handler"]
  RUN --> ST["settle：写入终态，释放 session gate"]
```

两道锁的顺序是固定的：先按“会话加命令 id”拿 key gate，再拿会话级 gate，而会话级 gate 一直持有到命令 settle，所以同一会话的不同命令按 CLI 实际受理的顺序串行（`command-inbox.ts:139`）：

```ts
      // 固定锁序：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`）：

```ts
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 一侧的受理、排队与引导见[输入受理、命令队列与引导](https://daiw.org/manual/zcode/prompt-admission)，形式化模型见[怎么读这份源码](https://daiw.org/manual/zcode/reading-the-source)。

## 快照、增量与重连

每个会话有一个 `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 会话库](https://daiw.org/manual/zcode/session-store)。

## 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`）：

```ts
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`）。审批选项与规则持久化见[权限模式与规则](https://daiw.org/manual/zcode/permission)，问卷的自动解决见 [Todo、提问与 Plan 模式](https://daiw.org/manual/zcode/interaction-tools)。

## 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 一侧按连接登记订阅的所有权，决定帧该路由给谁、哪些退订请求可以转发，见[桌面应用](https://daiw.org/manual/zcode/desktop)与[远程工作区与手机远控](https://daiw.org/manual/zcode/remote)。

## 会话驻留

一个 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 一侧的驻留事实见[会话事件流与持久化投影](https://daiw.org/manual/zcode/session-events)。

下一篇：[桌面应用：Electron 的三层](https://daiw.org/manual/zcode/desktop)——协议的另一端：Main、Host 与 Renderer 怎样分工，Host 怎样拉起并管理这个 Agent 子进程。
