一条消息的旅程
从回车到屏幕:一条输入在终端与桌面两条路上各经过哪些层——命令中心与 ZCodeApp、受理与命令队列、回合准备与循环、模型流式与工具执行、事件与持久化,最后怎样回到界面;两条路在哪里汇合、又在哪里分开。
前三篇从外面看 ZCode:产品形态、仓库结构、阅读方法。这一篇换个角度,跟着一条消息走一遍。设想你在工作区里输入“把 README 里的错别字改掉”并回车:Agent 读文件、找出错字、调用 Edit 改掉、再回一句话。这条消息在终端 TUI 与桌面端各有一条路,两条路在 AgentRuntime 汇合,之后的回合、模型与工具完全相同,最后又各自以不同的方式回到屏幕。
这里只走主干,每一站都给出入口与详写的篇目。先走终端那条路,它在同一个进程里,站点最少。
第一站:输入框
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 本身不持有会话,也不知道运行时长什么样,它只拿到一组回调,这是 终端界面 一篇讲的边界。
第二站:命令中心与 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)。ZCodeApp 的输入门面做几件运行时之外的准备(apps/zcode-cli/packages/bootstrap/src/app/input-facade.ts:369、input-facade.ts:99):
- 首次真实执行前定下 Shell 环境快照,需要时从会话库恢复历史;
- 把附件交给产物库外置,只留引用;
- 记一条输入历史,供
↑翻看; - 自定义命令展开成真正发给模型的提示词,原文留作展示。
最后调用 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)在下一个工具批次之后注入,不能的就排到本轮之后。三种回执、两条车道与各自的消费边界,见输入受理、命令队列与引导。
第四站:回合准备
executeTurnCommand 在任何 await 之前先冻结本轮的事实:用哪个模型、什么输出风格(turn.ts:100),之后再切模型只影响下一轮。随后依次:
| 步骤 | 做什么 | 详见 |
|---|---|---|
| 建模型 | 按冻结的选择向模型工厂要一个模型句柄 | 模型适配层 |
| 初始化上下文 | 首轮取一次上下文源快照:环境、git 状态、AGENTS.md、记忆索引,拼出系统提示词前缀 | 系统提示词、上下文与提醒 |
| SessionStart 钩子 | 每个运行时只跑一次 | 生命周期 Hooks |
发 TurnStarted | 界面据此显示“正在工作” | 会话事件流 |
| UserPromptSubmit 钩子 | 钩子可以拦下这条输入、不请求模型 | 生命周期 Hooks |
| 写入用户消息 | 进内存历史,并由 persistUserPrompt 落库(apps/zcode-cli/packages/core/src/runtime/methods/message-persistence.ts:29);首条消息落库后立即异步生成会话标题(turn.ts:530) | 会话事件流 |
第五站:回合循环
准备完毕,进入 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,压缩见上下文压缩。
第六站:一次模型请求
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。
截断后的续写、断流后的恢复、错误分类与两层重试,见一次模型请求:流式、工具并发与恢复。
第七站:工具执行
读完文件,模型的下一步请求里带着一个 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,循环再请求一次模型;这一次模型只回一句“改好了”,没有新的工具调用,回合结束。
执行器内部见执行器:调度、审批、超时与结果,Edit 本身见读、写、改、搜,四种模式与规则见权限模式与规则。
第八站:回到屏幕
一路上每一件事都以会话事件的形式发出。所有事件都从 appendEvent 出去(apps/zcode-cli/packages/core/src/runtime/methods/events.ts:81):事件存储分配序号,少数事件写进会话条目与输入账本,记一笔用量,再依次交给订阅者。消息与 part 不从事件推导,而是由运行时在同一段代码里并排写进 SQLite,冷恢复靠的是它们,见会话事件流与持久化投影。
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:
- 界面到 Host。每个窗口有一个 Host 进程,Renderer 与它之间是一条 MessagePort 上的 RPC(见桌面应用)。发送一条消息,就是界面的会话传输层把一个命令信封交给 Host(
packages/ui/src/v4/agentConversationTransport.ts:350),Host 的 Agent 服务把它转成一条v4/command请求(packages/services/src/zcode-agent/zcodeAgentService.ts:5026)。 - Host 到 Agent。Agent 是 Host 用
app-server --stdio拉起的子进程(packages/services/src/zcode-agent/zcodeAgentProcessManager.ts:369),一个工作区一个进程,里面托管该工作区的全部会话。双方在标准输入输出上一行一个 JSON,分帧只认换行(packages/services/src/zcode-agent/zcodeStdioTransport.ts:61)。 - 命令受理。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)。从这里起就回到了终端那条路的第三站。 - 事件回流。运行时的事件不直接发给界面,而是先由协议层的
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)。断线重连时按水位续传增量,续不上就发快照。 - 审批。需要问人时,Agent 同时走两条路:一条反向请求
interaction/requestPermission发给 Host,一条作为pendingInteractions出现在投影里,哪边先应答算哪边(apps/zcode-cli/packages/bootstrap/src/zcode-protocol/interaction-response-race.ts:20)。
帧格式、34 种命令、快照与增量、两种投递档位的全部细节,见 ZCode Protocol V4。
两条路对照
| 环节 | 终端 TUI | 桌面端与 Web |
|---|---|---|
| 进程 | 界面与运行时同进程 | Host 拉起 app-server 子进程,一个工作区一个 |
| 提交入口 | 空闲走 submitPrompt 到 executeTurn,忙碌走 sendInput 到 admitPrompt | 一律 v4/command sendText,再到 sendInput 与 admitPrompt |
| 受理之前 | 命令中心处理斜杠命令 | CommandInbox 查重、串行、并发检查 |
| 审批 | broker 转发给当前提交登记的 TUI 回调 | 反向请求与 pendingInteractions 竞速 |
| 回到界面 | 原始会话事件直接驱动 React 状态 | 归约成行与状态,按档位攒帧推送 |
| 断线恢复 | 不需要 | 按水位续传增量或发快照 |
| 回合内部 | 相同 | 相同 |
两条路的分叉点和汇合点都很清楚:分叉在 ZCodeApp 之外,汇合在 ZCodeApp.sendInput 与 submitPrompt。这正是仓库全景里那条进程边界的意义——运行时只管会话,怎样被驱动、结果怎样呈现,交给外面的宿主。
下一篇:AgentRuntime:端口、依赖与方法装配——两条路汇合的那个对象:一个会话一个实例,它持有什么状态、依赖哪些端口,近百个方法文件又怎样装到同一个类上。