# 一条消息的旅程

> 从回车到屏幕：一条输入在终端与桌面两条路上各经过哪些层——命令中心与 ZCodeApp、受理与命令队列、回合准备与循环、模型流式与工具执行、事件与持久化，最后怎样回到界面；两条路在哪里汇合、又在哪里分开。

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

前三篇从外面看 ZCode：产品形态、仓库结构、阅读方法。这一篇换个角度，跟着一条消息走一遍。设想你在工作区里输入“把 README 里的错别字改掉”并回车：Agent 读文件、找出错字、调用 Edit 改掉、再回一句话。这条消息在终端 TUI 与桌面端各有一条路，两条路在 `AgentRuntime` 汇合，之后的回合、模型与工具完全相同，最后又各自以不同的方式回到屏幕。

这里只走主干，每一站都给出入口与详写的篇目。先走终端那条路，它在同一个进程里，站点最少。

```mermaid
sequenceDiagram
    participant U as 用户
    participant T as TUI<br/>app-submit
    participant C as cli 提示处理器<br/>命令中心
    participant A as ZCodeApp<br/>输入门面
    participant R as AgentRuntime<br/>命令队列
    participant L as 回合循环
    participant M as 模型适配层
    participant X as 工具执行器

    U->>T: 回车
    T->>C: submitPrompt（带 onEvent 与审批回调）
    C->>C: 不是斜杠命令，检查登录
    C->>A: app.submitPrompt
    A->>R: executeTurn，排入 prompt 命令
    R->>L: executeTurnCommand：准备后进入循环
    L->>M: runModelBackedTurnStep，streamText
    M-->>L: 文本增量与 tool_call
    L->>X: Read、Edit 等工具调用
    X-->>C: 需要审批时经 permissionBroker 转给 TUI
    X-->>L: 工具结果，按声明顺序写回历史
    L->>M: 下一次请求
    M-->>L: 只有文本，回合结束
    R-->>T: 一路上的 SessionEvent 经订阅回到界面
    A-->>T: TurnResult
```

## 第一站：输入框

TUI 的输入框是 OpenTUI 的 `textarea`，`Enter` 被映射成提交、`Shift+Enter` 是换行（`apps/zcode-cli/packages/tui/src/app-input-pane.tsx:35`）。提交交给 `app.tsx` 里的 `submitValue`（`apps/zcode-cli/packages/tui/src/app.tsx:246`），它按当前是否忙碌分两路：

- **空闲**：`submitIdleTurn`（`apps/zcode-cli/packages/tui/src/app-submit.ts:201`）清空草稿、置忙、先把用户这一行画进转录区，然后调用 `options.submitPrompt`，同时交出两个回调：`onEvent` 用来接收本回合的会话事件，`requestPermission` 用来弹审批面板（`app-submit.ts:256`）。
- **忙碌**：`submitDuringActiveTurn` 改调 `options.sendInput`，交付方式为 `auto`，并带上当前回合的 id（`app-submit.ts:151`）。运行时回 `queued` 时，这条消息先放进输入框上方的队列区，不进转录，因为它还没进入模型的上下文。

`options` 里的这些函数都来自 cli 包：TUI 本身不持有会话，也不知道运行时长什么样，它只拿到一组回调，这是 [终端界面](https://daiw.net/manual/zcode/tui) 一篇讲的边界。

## 第二站：命令中心与 ZCodeApp

回调的另一端是 cli 的提示处理器。`submitPrompt`（`apps/zcode-cli/packages/cli/src/tui-prompt-handler.ts:304`）先把 TUI 给的审批回调登记为“当前处理函数”，再把输入交给命令中心（`tui-prompt-handler.ts:309`）。命令中心先看它是不是斜杠命令：`/model`、`/mode`、`/resume` 这类在本地处理，自定义命令展开成提示词；普通文本则在确认已登录之后，直接交给 `app.submitPrompt`（`apps/zcode-cli/packages/cli/src/command-center/create.ts:44`、`create.ts:53`）。

`app` 是 bootstrap 创建的 `ZCodeApp`，第一次用到时才由 `createZCodeApp` 装配出来，一个 `ZCodeApp` 对应一个会话、里面恰好一个 `AgentRuntime`（`apps/zcode-cli/packages/bootstrap/src/app/create-app.ts:146`，装配过程见 [bootstrap](https://daiw.net/manual/zcode/bootstrap-assembly)）。`ZCodeApp` 的输入门面做几件运行时之外的准备（`apps/zcode-cli/packages/bootstrap/src/app/input-facade.ts:369`、`input-facade.ts:99`）：

1. 首次真实执行前定下 Shell 环境快照，需要时从会话库恢复历史；
2. 把附件交给产物库外置，只留引用；
3. 记一条输入历史，供 `↑` 翻看；
4. 自定义命令展开成真正发给模型的提示词，原文留作展示。

最后调用 `runtime.executeTurn`（`input-facade.ts:127`）。忙碌那一路走的是门面的 `sendInput`，它调 `runtime.admitPrompt`（`input-facade.ts:216`），由运行时当场决定开跑、排队还是拒绝。

## 第三站：受理与命令队列

`executeTurn` 不直接开跑，而是把输入包成一条 `prompt` 运行时命令放进命令队列（`apps/zcode-cli/packages/core/src/runtime/methods/turn.ts:70`）。队列串行排水，轮到它时，`runRuntimeCommand` 为它建一个前台执行、再调 `executeTurnCommand`（`apps/zcode-cli/packages/core/src/runtime/methods/runtime-command-queue.ts:205`），这才是回合真正的起点（`turn.ts:93`）。

这一层要回答的问题是：Agent 正在干活时又来一句话怎么办。空闲时 `admitPrompt` 当场分配回合 id、建立启动预留，保证第二次受理一定看到“忙”；忙碌时，能并进当前回合的作为引导（guide）在下一个工具批次之后注入，不能的就排到本轮之后。三种回执、两条车道与各自的消费边界，见[输入受理、命令队列与引导](https://daiw.net/manual/zcode/prompt-admission)。

## 第四站：回合准备

`executeTurnCommand` 在任何 `await` 之前先冻结本轮的事实：用哪个模型、什么输出风格（`turn.ts:100`），之后再切模型只影响下一轮。随后依次：

| 步骤 | 做什么 | 详见 |
| --- | --- | --- |
| 建模型 | 按冻结的选择向模型工厂要一个模型句柄 | [模型适配层](https://daiw.net/manual/zcode/model-adapters) |
| 初始化上下文 | 首轮取一次上下文源快照：环境、git 状态、`AGENTS.md`、记忆索引，拼出系统提示词前缀 | [系统提示词、上下文与提醒](https://daiw.net/manual/zcode/context-builder) |
| SessionStart 钩子 | 每个运行时只跑一次 | [生命周期 Hooks](https://daiw.net/manual/zcode/hooks) |
| 发 `TurnStarted` | 界面据此显示“正在工作” | [会话事件流](https://daiw.net/manual/zcode/session-events) |
| UserPromptSubmit 钩子 | 钩子可以拦下这条输入、不请求模型 | [生命周期 Hooks](https://daiw.net/manual/zcode/hooks) |
| 写入用户消息 | 进内存历史，并由 `persistUserPrompt` 落库（`apps/zcode-cli/packages/core/src/runtime/methods/message-persistence.ts:29`）；首条消息落库后立即异步生成会话标题（`turn.ts:530`） | [会话事件流](https://daiw.net/manual/zcode/session-events) |

## 第五站：回合循环

准备完毕，进入 `runRegularTurnLoop`（`apps/zcode-cli/packages/core/src/runtime/methods/turn-loop.ts:43`）。它是一个 `while (true)`，每一轮先整理再请求：吸收后台任务通知、判断要不要压缩上下文、确保 MCP 工具已注册、按本回合的禁用表算出工具清单、补上 Plan 模式与 Todo 等提醒，最后把历史投影成 provider 请求、打好缓存锚点，调用一次 `runModelBackedTurnStep`（`turn-loop.ts:205`）。

循环不以工具调用次数设上限，靠压缩、续写、恢复与重复调用检测收住失控，只有模型本步没有要执行的工具、也没有待注入的引导与 Stop 钩子续跑时才退出。每一轮的细节见[回合循环与 TurnMachine](https://daiw.net/manual/zcode/turn-loop)，压缩见[上下文压缩](https://daiw.net/manual/zcode/compaction)。

## 第六站：一次模型请求

`runModelBackedTurnStep`（`apps/zcode-cli/packages/core/src/runtime/methods/turn-model-step.ts:88`）先落一条空的助手消息、发 `ModelRequest` 事件、按剩余窗口算好输出预算，再经适配层发起流式请求。适配层用的是 Vercel AI SDK 的 `streamText`（`apps/zcode-cli/packages/adapters/src/model/model.ts:76`），把三种 API 形态的流统一成一套事件；运行时这一侧用一个 `for await` 逐个消费（`apps/zcode-cli/packages/core/src/runtime/methods/model.ts:233`）：

- 文本与思考增量累加进结果，同时投影成 `ModelStreaming` 会话事件，经一条有序写队列发出去；
- 定稿的 `tool_call` 交给流式工具协调器。在这个例子里，模型先调用 `Read` 看 README，它只读、并发安全、没有副作用，协调器不等流结束就单独执行它；
- `finish` 带回结束原因与用量，模型步发 `ModelComplete`。

截断后的续写、断流后的恢复、错误分类与两层重试，见[一次模型请求：流式、工具并发与恢复](https://daiw.net/manual/zcode/model-step)。

## 第七站：工具执行

读完文件，模型的下一步请求里带着一个 `Edit` 调用。模型步把本步的全部工具调用交给 `executeToolCallsForModelStep`（`apps/zcode-cli/packages/core/src/runtime/methods/turn-tools.ts:43`），调度器按只读与并发安全分组，每个调用走一遍执行器的流水线 `executeToolCall`（`apps/zcode-cli/packages/core/src/tool/executor/call-runner.ts:65`）：查找工具、归一化并校验入参、工具自校验、PreToolUse 钩子、权限判定、带超时执行、校验并序列化结果、PostToolUse 钩子，最后发 `ToolCallResult`。

`Edit` 会写工作区。在缺省的 `build` 模式下权限服务判定为“要问”，执行器经运行时的 `permissionBroker` 发出审批请求。TUI 给 `createZCodeApp` 的 broker 只是个转发器，把请求交给第二站登记的那个处理函数（`tui-prompt-handler.ts:78`）；TUI 把它包成一个待决的 Promise，画出审批面板（`apps/zcode-cli/packages/tui/src/app-permission.ts:31`），你按下允许，Promise 兑现，Edit 才真正执行。结果按模型写出的顺序写回历史，模型步返回 `continue`，循环再请求一次模型；这一次模型只回一句“改好了”，没有新的工具调用，回合结束。

执行器内部见[执行器：调度、审批、超时与结果](https://daiw.net/manual/zcode/tool-executor)，Edit 本身见[读、写、改、搜](https://daiw.net/manual/zcode/file-tools)，四种模式与规则见[权限模式与规则](https://daiw.net/manual/zcode/permission)。

## 第八站：回到屏幕

一路上每一件事都以会话事件的形式发出。所有事件都从 `appendEvent` 出去（`apps/zcode-cli/packages/core/src/runtime/methods/events.ts:81`）：事件存储分配序号，少数事件写进会话条目与输入账本，记一笔用量，再依次交给订阅者。消息与 part 不从事件推导，而是由运行时在同一段代码里并排写进 SQLite，冷恢复靠的是它们，见[会话事件流与持久化投影](https://daiw.net/manual/zcode/session-events)。

TUI 是订阅者之一，而且订了两次：第一站交出的 `onEvent` 只管本回合，cli 另有一条常驻的事件中继挂在 `runtime.subscribeEvents` 上，接住后台通知驱动的回合这类“回合之外”的事件（`apps/zcode-cli/packages/cli/src/tui-session-event-relay.ts:24`）。TUI 入口先过主会话闸门，挡掉子 Agent 带来的事件，再按事件 id 去重（`apps/zcode-cli/packages/tui/src/app-session-event-handler.ts:34`）；`model_streaming` 按助手消息 id 把增量追加到对应的文本或思考片段（`apps/zcode-cli/packages/tui/src/app-model-streaming.ts:11`），每个增量就是一次 React 状态更新，刷新频率由渲染器的 30 帧封顶。

回合结束时运行时发 `TurnComplete`（`turn.ts:647`），调度一次项目记忆抽取（`turn.ts:699`），`executeTurn` 的 Promise 带着 `TurnResult` 兑现，沿第二站、第一站原路返回，TUI 用它补齐最终结果、解除忙碌状态。

## 另一条路：桌面端

桌面端的消息从 Renderer 出发，要跨两道进程边界才到 Agent。前半段是界面与 Host 之间的 RPC，后半段是 Host 与 Agent 子进程之间的 ZCode Protocol：

```mermaid
sequenceDiagram
    participant UI as Renderer<br/>@zcode/ui
    participant H as 窗口 Host<br/>ZCodeAgentService
    participant P as Agent 子进程<br/>app-server
    participant G as V4 网关<br/>CommandInbox
    participant R as AgentRuntime

    UI->>H: sendCommand（MessagePort 上的 RPC）
    H->>P: v4/command sendText（stdin 一行 JSON）
    P->>G: 校验信封，幂等查重，同会话串行
    G->>R: app.sendInput，delivery 为 start_turn
    R-->>G: 受理回执 started 或 queued
    G-->>H: ACK（stdout 一行 JSON）
    Note over R: 第三站到第七站与终端完全相同
    R-->>G: SessionEvent
    G->>G: ProductProjection 归约成行与状态
    G-->>H: v4/conversation/frame，每 30 毫秒一帧
    H-->>UI: 转发帧，界面按增量更新
```

1. **界面到 Host**。每个窗口有一个 Host 进程，Renderer 与它之间是一条 MessagePort 上的 RPC（见[桌面应用](https://daiw.net/manual/zcode/desktop)）。发送一条消息，就是界面的会话传输层把一个命令信封交给 Host（`packages/ui/src/v4/agentConversationTransport.ts:350`），Host 的 Agent 服务把它转成一条 `v4/command` 请求（`packages/services/src/zcode-agent/zcodeAgentService.ts:5026`）。
2. **Host 到 Agent**。Agent 是 Host 用 `app-server --stdio` 拉起的子进程（`packages/services/src/zcode-agent/zcodeAgentProcessManager.ts:369`），一个工作区一个进程，里面托管该工作区的全部会话。双方在标准输入输出上一行一个 JSON，分帧只认换行（`packages/services/src/zcode-agent/zcodeStdioTransport.ts:61`）。
3. **命令受理**。Agent 这一侧，命令先进 `CommandInbox`（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/command-inbox.ts:107`）：按 `commandId` 查重，同一会话的命令排成一队，再按修订号做乐观并发检查。`sendText` 做完协议层的校验，调用同一个 `ZCodeApp` 的 `sendInput`，交付方式固定为 `start_turn`（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/commands/prompt-turn.ts:103`、`prompt-turn.ts:111`）。从这里起就回到了终端那条路的第三站。
4. **事件回流**。运行时的事件不直接发给界面，而是先由协议层的 `ProductProjection` 归约成“会话状态加一组对话行”（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/product-projection.ts:2`），再由每个会话的发布器按订阅者攒帧：桌面的 `desktop-continuous` 档位每 30 毫秒一帧、流式增量全发，手机与 Web 的 `web-remote-replayable` 档位 150 毫秒一帧、只流助手正文（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol-v4/v4-gateway.ts:3285`）。断线重连时按水位续传增量，续不上就发快照。
5. **审批**。需要问人时，Agent 同时走两条路：一条反向请求 `interaction/requestPermission` 发给 Host，一条作为 `pendingInteractions` 出现在投影里，哪边先应答算哪边（`apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-response-race.ts:20`）。

帧格式、34 种命令、快照与增量、两种投递档位的全部细节，见 [ZCode Protocol V4](https://daiw.net/manual/zcode/zcode-protocol)。

## 两条路对照

| 环节 | 终端 TUI | 桌面端与 Web |
| --- | --- | --- |
| 进程 | 界面与运行时同进程 | Host 拉起 `app-server` 子进程，一个工作区一个 |
| 提交入口 | 空闲走 `submitPrompt` 到 `executeTurn`，忙碌走 `sendInput` 到 `admitPrompt` | 一律 `v4/command sendText`，再到 `sendInput` 与 `admitPrompt` |
| 受理之前 | 命令中心处理斜杠命令 | `CommandInbox` 查重、串行、并发检查 |
| 审批 | broker 转发给当前提交登记的 TUI 回调 | 反向请求与 `pendingInteractions` 竞速 |
| 回到界面 | 原始会话事件直接驱动 React 状态 | 归约成行与状态，按档位攒帧推送 |
| 断线恢复 | 不需要 | 按水位续传增量或发快照 |
| 回合内部 | 相同 | 相同 |

两条路的分叉点和汇合点都很清楚：分叉在 `ZCodeApp` 之外，汇合在 `ZCodeApp.sendInput` 与 `submitPrompt`。这正是[仓库全景](https://daiw.net/manual/zcode/monorepo-map)里那条进程边界的意义——运行时只管会话，怎样被驱动、结果怎样呈现，交给外面的宿主。

下一篇：[AgentRuntime：端口、依赖与方法装配](https://daiw.net/manual/zcode/agent-runtime)——两条路汇合的那个对象：一个会话一个实例，它持有什么状态、依赖哪些端口，近百个方法文件又怎样装到同一个类上。
