Agent Harness 设计手册
The Harness Playbook 原文发布于
开篇先说一声谢谢。数十万人用过 omp,报告了哪里坏了,提出了缺什么,并塑造了它最终的模样。这篇文章,以及 omp² 本身,都因你们而存在。
听说 omp² 的消息后,很多人脱口而出:“可是,为什么?”
一个套着 fetch 的 while 循环听起来很简单,但 OpenCode、Pi、OpenClaw 和 omp 同时都在做彻底重构,这是有原因的:这类软件此前并不存在,只有先从简单版本做起,我们才能看见裂缝,进而朝更好的版本迈进。
无法避免的复杂度需要有人来承担。眼下,复杂度守恒的天平倾向了扩展和用户那一侧,导致根本无法在 omp 或 Pi 之上写出可靠的软件。我已经能听到有人喊:“什么?它扩展起来明明这么简单、这么舒服。” 给我几章的篇幅,让我来改变你的看法。
Dijkstra 写过“简单是可靠的先决条件”,然而他成名的事迹却是用算法解决寻路问题。为什么不直接暴力搜索呢?他丝毫没有在主张我们如今反复念叨的那句简单即好,复杂即坏。那条建议本是为了帮助实现者思考,我们却可耻地拿它当借口,让实现者免于思考。
Ousterhout 在他斯坦福课程的讲义里补上了缺失的另一半。他告诉模块作者要“拥抱苦难”:接下难题,彻底解决,再让结果对其他所有人都易于使用。把复杂度往下压进模块里。让少数几个实现者来背负它,而不是让每个调用方各自背负一份更小、又略有不同的副本。
我相信很多读者还记得那条把 Claude Code 比作游戏引擎的推文所引发的一波梗图。这个类比听起来有些牵强,但如果你把 harness 的职责逐条列出来,撇开渲染不谈,两者确实对得相当整齐。
它维护一个权威的世界,把变更记入日志(journal),执行不受信任的动作,把状态复制到多个视图,调度各类 actor,解释命令,适配互不兼容的协议,还要渲染一个实时界面。
听着耳熟?看来游戏引擎已经花了几十年时间,承担着同样这几类复杂度。
接下来的内容既是一份复盘,也是一本手册:
- omp 教会我们的:点出我们在一个真正被人使用的系统里遭遇的故障。
- omp² 的改变:描述替代架构,其中一部分已经建成,另一部分仍在推进。
设计边界
在讨论 Agent Harness 的任何子系统之前,先设想有四个截然不同的产品将依赖于它:
- 多路复用工作区 一个本地环境,多个 Agent 与 subagent 在同一个文件夹里工作。
- 远程操控者 一个远程客户端,用手机驱动云端 Agent,或者驱动自己桌子底下的那台机器。
- 旁观者 一个 Web 客户端,观看一个 Claude Agent 工作。
- Factorio 一个自动化软件工厂,用 SDK 处理不受信任的输入。
这些不是市场营销里的用户画像,而是架构测试。它们合在一起,在那些让 harness 不再只是一个聊天循环的维度上各不相同:
| 测试 | 本地还是远程 | 交互式还是自主式 | 信任边界 | 并发 |
|---|---|---|---|---|
| 多路复用工作区 | 本地 | 交互式 | 基本受信任 | 多个 Agent,一个工作区 |
| 远程操控者 | 远程 | 交互式 | 宿主/客户端分离 | 一个或多个 Agent |
| 旁观者 | 远程视图 | 观察式 | 不受信任的展示层输入 | 多个观看者 |
| Factorio | 远程或集群 | 自主式 | 有敌意的仓库与工具输入 | 多个任务 |
只能应付第一种情况的设计,往往会把控制器偷偷塞进 TUI,把状态存在闭包里,让扩展在引擎进程内执行,并假定总有人类能从一次无界的调用中收拾残局。能在全部四种情况下存活的设计,则被迫划出更好的边界。
全文余下的部分沿着五条推论展开:
- 唯一的权威会话。 回退、分叉、恢复、复制与检视,都必须从同一份记入日志的状态推导出来。
- 受信任的控制平面。 策略与会话所有权留在宿主上;沙箱只接收有界的执行请求。
- 有界的工作。 工具调用、subagent 与后台任务都是可取消的流,受中央限额约束并具备可观测性。
- 显式的兼容性。 模型与提供商的怪癖是结构化的知识,而不是散落在各个调用点的分支。
- 视图即投影。 TUI、Web 客户端、远程客户端与 subagent 检视器渲染的是同一份状态,而不是各自成为额外的权威来源。
这些约束是把后文一切串起来的纽带。当后面的章节提出一个 DOM、一个 convar、一个 Director、一个微型 VM stub 或一个组件渲染器时,它都是在解决这五条要求之一,而不是为了炫技而引入一个巧妙的子系统。
第一条要求是根基:在决定代码在哪里运行、如何渲染之前,harness 得先知道什么才是真的。
状态
什么必须留存
如果你想让某样东西持久、可回退、耐崩溃、可分叉,你有三种选择:
- 保存产生它的历史。
- 保存你关心的那些属性的变更。
- 保存机器本身。

Source 引擎在网络同步上用的是第二种选择的一个变体。omp 和 Pi 目前用的是……哪一种都没有贯彻到底。事件是有的,但状态并不真正源自这些事件,这违反了事件溯源的第一原则:状态必须仅凭事件就能推导出来。
omp 教会我们的:两个权威来源
replay(.dem) == original。Pi 的日志只覆盖消息树,而权威状态却活在树外——回退、分叉、恢复全都在说谎。走到这一步,原因是可以理解的。在每条日志里重复系统提示词和 AGENTS.md 会很浪费;这可以通过对模板做哈希并只存储其变量来解决。而且这种状态建模风格在 TypeScript 里并不常见,毕竟 TypeScript 实际上并没有运行时类型。
然而,结果依然是两个事实来源:
| Source 引擎 | Pi 风格的 harness | |
|---|---|---|
| 事实来源 | entity list,仅此而已。服务器负责模拟;客户端负责预测。 | 消息树外加 todo 状态、重试计数器、subagent 注册表、流式传输标志,以及其他对持久化不可见的状态 |
| Δ 的单位 | { Δ entity ... },覆盖每一个字段,因为每个增量都是实体增量 | message / custom / custom_message,没有引擎自有的折叠(fold)逻辑;每个扩展都各自手搓推导 |
| 全局状态 | CCSGameRules 是一个单例实体。没有特殊情况。 | 三个层级,其中一个能用 |
| 插件状态 | 插件写入实体字段,所以状态默认就会被网络同步和回放 | 模块级闭包:let turnCount = 0、new Map()、new Set() |
| 回放 | 加载 .dem,定位到某个 tick,重新推导 | 加载 .jsonl;叶子指针在移动,而其他权威来源要么被重置,要么随意地存活下来 |
“全局状态”这一行是好玩的地方。Source 没有会话级全局状态;它们不过是某个实体的属性。而我们的全局状态自有一套层级:
Source 的正确性并非来自写一个精心设计的 reconciler 或者优秀的文档。它让不可回放的状态无法被表示。正确性来自这一约束,而不是指望每个扩展作者都记得注册两个 hook、再定义一个更新的形状。
证据:在 API 里,正确性是可选项
我们查看了 78 个官方 Pi 扩展示例。其中 60 个是无状态的;在 17 个带状态的示例里,只有两个是正确的。
| 示例 | 逃出权威之外的状态 | 用户可见的故障 |
|---|---|---|
git-checkpoint.ts | 检查点引用由一个临时的 Map 持有 | /fork 在 agent_settled 已经清掉检查点之后才运行 |
plan-mode/index.ts | Plan 模式从整个文件恢复,而不是从选中的分支 | 回退后限制仍然生效;恢复可能复活一条已死的分支 |
status-line.ts | turn 计数放在闭包里 | 从第 3 轮回退到第 1 轮会得到第 4 轮;恢复后从零开始 |
dynamic-tools.ts | 活跃的扩展注册表 | 工具在回退后还在,恢复后却消失了 |
snake.ts | 恢复时会扫描已放弃的分支 | 一条已死分支上的存档回来了 |
bookmark.ts | “最后一条”指的是文件顺序上的最后一条 | 已放弃分支上一条隐藏的助手消息被加了书签 |
kimi-deferred-tools.ts | 活跃工具清单没有被重新推导 | Calculator 在其被发现的时间点之前就已处于活跃状态 |
auto-commit-on-exit.ts | 关闭逻辑把进程退出和会话切换混为一谈 | /new、/resume 或 /fork 都会提交 worktree |
tic-tac-toe.ts | 实时写入与恢复读取使用不同的条目类型 | 一次崩溃就能让用户的落子消失 |
细节见附录 A,但重点在于:写文档并不能修复这样一种 bug 分布。引擎需要让状态只能存在于唯一一处。
tic-tac-toe.ts:下一步 X,在 O 回应之前崩溃,恢复,X 没了。实时写入与恢复读取使用不同的条目类型。
omp² 的改变:一个物化的会话
如果整个会话物化为一个 DOM 会怎样?当然,你也可以用带序列化的 ECS 系统,或者任何你想要的表示格式;我选择 XML 主要是因为它让状态非常容易组合、检视和调试。
<meta>
<todo>…</todo> <!-- persistent components, journal-derived -->
<jobs>…</jobs>
</meta>
<body> <!-- the live chain, entries as elements -->
<user id="e12">…</user>
<ai id="e13">…</ai>
<Read id="e14" status="ok">
<input path="src/main.rs:1-80"/>
<result lines="80">…</result>
</Read>
</body>
<queues>
<steering>...</steering>
<prompts>...</prompts>
</queues>
它的事件是一条属性变更流:
: todo.done
event: patch@1
by: e41
data: {"ops":[["set",412,"status","completed"],["set",415,"status","in_progress"]]}
这棵树就是权威;日志存储它的增量变更。运行时对象可以缓存或索引它,但它们不会成为事实栖身的第二个地方。在日志的任何一个点上,harness 都能物化整个会话,从而也就能对它做快照。
唯一权威换来了什么
状态与 transcript 同在一棵树里,好几个难题就归结为同一种操作。
回退就是一次 DOM diff。 把当前的物化结果与目标状态做 diff。一个 <subagent> 元素消失了?销毁该元素,从而终止它。一个元素出现了?创建该元素,从而恢复或 spawn 它。这份差异本身就是完整的生命周期工作清单。
新增一个带状态的功能,永远不会给回退、分叉、恢复或复制多添一个调用点。
提示词成为投影。 不再有一个 100 行的状态对象被塞进每一个模板。系统提示词读取的是和其他一切相同的那棵树:
- {{ count(select("todo item[status!=completed]")) }} open items
复制成为订阅。 我们已经有了变更的应用逻辑和推导逻辑。远程客户端消费补丁流,而不是 tail 一个文件。远程操控者和旁观者这两种情况不再需要单独的状态管道。
渲染成为投影。 组件注册表可以从同一份元素状态渲染 Read、Bash、一条消息或一个 subagent。流式传入的参数修改 <input>;流式产出的输出修改 <result>。第七章会把它做成一个类型化的接口,而不是又一个手工定制的渲染器。
控制器与 actor
这种分离也让 subagent 变得可检视。Pi 的视图直接读取实时会话状态(页脚会调用 sessionManager.getEntries()),因此要加上“检视 subagent”的功能,就意味着得把控制器状态一路穿过 UI 内部传递过去。
让控制器和 actor 彻底分离:控制器拥有会话状态;actor 只渲染它的快照和补丁流。TUI、远程客户端和 subagent 检视器于是成了平级的同类。检视一个 subagent,就是把同一个 actor 指向那个 subagent 的状态。
一个真实可信的状态模型是根基,但如果由不受信任的代码掌握着修改它的策略,这个根基仍然会被掏空。下一章将划出运行时的边界。
运行时
“状态”一章确立了 harness 所相信的东西。“运行时”一章则要决定谁有权改变它、不受信任的工作在哪里运行,以及当执行可能持续数小时、不断流式输出、或者对一句礼貌的停止请求置之不理时,“工具调用”到底意味着什么。
沙箱只管执行,不做决策
先从设计边界里的 Factorio 用例说起。假设我们克隆一份 roboomp,让 gpt spark 把所有提到它名字的地方都换成 CodeWhatever,然后开始靠这套“神奇技术”向人收取几千块。谁来运行工具?废话,当然是 VM。才怪。
把执行器放进 VM 后会发生什么呢:
嗯,这行不通。因为:
- 程序化的工具使用需要访问全部工具;所以我们没法随意把 harness 状态类工具和环境状态类工具拆开
- 我们得建一个双工网关,让 VM 能调用宿主工具;而这,
- 违背了初衷(要么你给 DoS 开了门;要么你得对自己 VM 的某些动作做限流)
- 只会让事情更复杂,谢谢,不必了。
好吧,那把驱动应用放进 VM 里!
- 现在我们不仅泄露了应用的提示词,还泄露了内部源码,除非把应用挪到 VM 外面、通过网络 RPC 连接 harness,同时把会话存储也挪出去
- 但会话存储在外面,意味着得给 VM 授予写权限,这又把我们带回了问题 #1 和 #2 的合体。
解决办法是在 VM 里只放一个唯命是从的 stub,然后非常非常小心地限制回传数据的最大量(你可不想因为一次误用的 Read 工具收到 2GB 的响应):
这几张图指向同一条边界:
- 宿主拥有会话状态、推理、策略、工具路由、审批、限制和日志记录。
- 沙箱通过一个小而顺从的协议负责环境执行。
- 每一条回传的流,都在不受信任的一侧耗尽宿主内存或上下文之前被限定了上限。
这种安排满足了 Factorio 用例,又不会让本地使用变差。同一个宿主可以把 stub 指向本地进程、容器、VM 或远程机器。
subagent 跨越同一条边界
放置问题不只是宿主与 VM 之争。subagent 在文件系统层也需要同样的边界:worktree 只隔离被跟踪的文件,而 pi-iso 借助 APFS、btrfs、ZFS、overlayfs、ProjFS 或兜底的复制方案,给每个 subagent 一份整个工作区的写时复制视图。subagent 自行分化;父级收到一份 diff。
subagent 拿到的是视图,交回的是变更。它并不共享父级的可变权威。这就是同一条宿主/沙箱规则在文件系统上的形态。
omp 教会我们的:一次调用,三个互不相干的 API
好,那我们该如何定义一个工具?我们最初做的那些改动稍后再谈,但核心契约基本原封不动地保留了下来:
export const myCustomTool: ToolDefinition = {
name: "my_tool",
parameters: mySchema,
// 1. Called during argument streaming & before execute()
renderCall(args, theme, context) {
if (context.argsComplete) {
// Trigger async preview computation
}
return new Text("Pre-execution preview UI...", 0, 0);
},
// 2. Main execution
async execute(_id, params) {
/* ... -> string */
},
// 3. Called after execute() settles
renderResult(result, options, theme, context) {
return new Text("Final execution result UI", 0, 0);
},
};
这份契约看起来小巧可人,但它把一个操作拆成了三个互不相关的阶段。预览、执行、给模型的结果、给人看的结果、诊断信息、流式更新、取消和日志记录,描述的全是同一次调用。这个 API 却让它们假装不是。
回调拆分导致重复劳动
首先,拆分渲染路径让响应式变成了一件需要主动选择加入的事。即便渲染出来的工具不会“啪”地切换成新形态,作者也得把大部分展示逻辑重复写一遍。
更大的问题在于 execute 的工作方式。以 Edit 为例:
renderCall会打开文件,但愿能把读到的部分缓存在某处(哪儿?),应用编辑,然后渲染出一份 diffexecute接着会再次打开文件,应用全部编辑、写入,并以对模型友好的格式返回一份 diffrenderResult随后拿到这份 diff,却得去解析我们定下的那个格式!为什么?因为人类当然想看带颜色、带高亮的版本,最好行号也更好看些。
这导致了一种凭本能写出的实现:
- 浪费 I/O 时间:文件被打开两次
- 浪费 CPU 时间:编辑的应用不是算一次,也不是两次,而是每变一个字符就从头再算一遍(renderCall 可不是协程!)
- 对任意格式做不必要的序列化/反序列化:为了实现 renderResult,我们得去解析模型输出(或者在 details 里塞一些片段,让记入日志的数据重复一份)
要让这事高效起来,你得实现一个在这份定义之外驱动的协程,找个地方存放它的句柄,而且结果反序列化那一整套活儿你仍然逃不掉。
问题不仅仅是代码重复。这份契约里没有一个权威对象,其状态能从“参数流式传入”经过“运行中”走到“已完结”。每个实现都得为这个生命周期自己发明一条旁路信道。
omp² 的改变:执行是一条状态流
此外也没有通用的办法来添加结构化的警告、诊断信息或截断提示。大多数 Pi 工具实现最终都写成了这样:
text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`;
模型于是只能猜测工具数据在哪里结束、harness 附加的说明从哪里开始。由于 execute 不是生成器,流式输出还得在更新信道之上再叠一层协议。
DOM 模型把这两个特例都消除了:
- 流式输出就是修改
<result>的主体; - 添加警告就是创建一个
<diag severity="warn">。
执行进行期间,客户端收到的是针对这份状态的补丁。一旦执行完结,相对于前一状态的最终 diff 会被记入日志。
在统一的会话模型里,一次调用就是一个带结构化子节点的元素:
<Edit id="e41" status="running" version="3">
<input i="Update the parser without changing the public API">…</input>
<result>…streaming structured state…</result>
<diag severity="warn">…</diag>
<usage tokens="0" elapsed-ms="842"/>
</Edit>
执行器在运行过程中修改这个元素。模型、用户、日志、远程客户端和测试 harness 观察到的是同一份状态的不同投影。完结时最终 diff 被冻结;没有哪个客户端需要去解析一个结果字符串,来还原序列化之前那个更丰富的对象。
限制是原语的一部分
Pi 的工具没有任何限制:返回 1 MB 文本,它就原封不动地转发给模型。这个原语太底层了,不适合直接暴露出去。
只在一处限定输出
Pi 自己在 Bash 和 Read 上就碰到了这个问题,它的回应是导出一个截断工具函数供各实现共用。omp 在这个工具函数之上加了一套 artifact 系统,让模型可以把保留下来的完整输出读回来,但责任仍然留在 Pi 放它的地方:每个实现自己头上。
把 1 MB 发给模型或许是一项值得保留的能力,但它应该是可以选择退出的——一个集中的实现,外加一个显式的 notrunc 属性——而不是让截断成为一种需要主动选择才能获得的良好设计。把这个辅助函数留作可选,会在两方面失败。
大多数工具都需要某种程度的截断,所以一个需要主动选用的辅助函数必然导致覆盖参差不齐:
- 不知道有这个辅助函数的作者会自己造一个,每个的提示语都略有不同;
- 从没想过结果会很大的作者则什么都不做。
在工具实现内部截断,而不是在对话渲染层截断,会破坏 Code 模式:
- Agent 在
Eval里永远无法信赖工具的输出;每次使用都得先把 harness 附加的说明从数据里解析剔除; Eval的结果本身也可能被截断,于是每次调用都在同一份数据外面叠上 N+1 层彼此独立的截断。
只在一处限定阻塞时间
把任何东西放到后台,以及给一次调用可以阻塞多久设上限,同样属于库这一层——而不是属于每一个恰好跑得很久的工具。
第一个理由是缓存和用户体验。否则一次意外漫长的调用会让 Agent 无法察觉并调整、用户回来时面对一个卡住的会话、自主任务无限期等待,而提供商的 KV 缓存则在调用返回前就过期了。
第二个理由是重复,这一点 omp 也搞错了。当每个工具都长出自己的后台化机制,每个工具也就都长出了自己的 spawn、poll、message、kill 和 list 辅助函数。看看 Claude 围绕它自己的 Task 和 Bash 工具画的这张图:
两者都收敛到进程的接口上:signal + stream in + stream out。后台化的 shell、subagent、开发服务器守护进程、远程函数,以及一次超出预算的普通调用,全都是同一种对象——一个带 stdin、stdout、退出状态和信号句柄的任务。一个 stdio 形态的任务原语就应该把它们全部封装起来。这样阻塞预算只在一处强制执行,输出只溢出到一条 artifact 路径,而检视、发消息或终止其中任何一个,都是同一个操作面,而不是每个工具各抄一份。
对可观测性的期待也以同样的方式收敛。想看 subagent 状态的用户,也想看后台化的 shell。跨 harness 实例给同伴发消息的 Agent,也想看到那些同伴运行的守护进程,这样同一目录下的 N 个 Agent 就能共用一个 HMR bun dev,而不是在 N 个端口上启动 N 份副本。
取消需要一条终止边界
扩展——因而也包括自定义工具——与引擎共享同一个 JavaScript isolate,会通向灾难。像样的热重载几乎不可能实现,而一次工具调用一旦逃出了协作式取消的掌控,就再也无法被强行停止。
JavaScript 和 Go 分别通过 AbortSignal 和 context.Context 暴露取消机制:有用的协议,但并非强制执行的协议。忘了传递信号、调用了一个不接受信号的依赖、跑了同步工作,或者陷入无限重试循环,那么超时只是告诉 Agent 继续往下走;那份工作本身可能还在后台持续烧资源。
因此一个安全的宿主需要一个它真正能够终止的执行单元——进程、worker、子解释器、VM 请求,或等价的边界,其死亡不会把会话权威一并带走。取消属于运行时契约,而不是寄望于每位工具作者的良好自觉。
让这条强制边界用起来舒服
一个刻意做得很笨的沙箱 stub 带来了最后一个 SDK 问题:扩展作者现在面对的是两个文件系统。不然的话,一个自定义的编辑函数可能得在一侧读取文件、整个传过去、再在另一侧写回。
这就是 omp² 为扩展选择 Python 的原因。Python 可以用标准库审视自己的 AST,把一个函数所需的源码打包起来,提交给另一个运行时;一个 @remote 属性就能把一个看起来像本地的函数变成 RPC。正是这一特性,让远程函数在 Modal 的 Python SDK 这类系统里用起来如此自然。
自带 Python 运行时还让 Eval 变得可靠,而不再取决于机器上碰巧装了哪个解释器。一石二鸟。
当工作有了可信的所有者和可取消的执行原语之后,harness 仍然需要一种连贯的方式来控制各种值和多 turn 行为。那就是控制平面。
控制平面
运行时掌握着两种不同类型的控制。值回答的是哪个模型、哪个服务档位、哪个主题或哪条策略当前生效。行为回答的是 Agent 是否可以 yield、是否必须再跑一轮、或者是否临时需要某项能力。一旦每个调用方都各自持有一个私有的 setter 或标志位,这两者都会变得混乱不堪。
值:策略随设置一同声明
配置系统同样成了一片雷区:既有脏数据跟踪,又有好几层配置(全局、会话级、临时……)。大多数 get/set 操作都像在 Pi 里那样经由 AgentSession 类型中转,因为变更必须持久化到 JSONL。
你知道哪个配置系统在多年前就把这些问题全部解决了吗?没错,Source 引擎!
尤其了不起的是,大多数碰过 Valve 游戏的人都能脱口而出 sv_cheats 是干什么的。这么多年来大家一直在自定义自己的配置,我却想不起有哪怕一个不满意的用户。其他任何软件的任何一项配置,你还能记得吗?
一个 convar 就是一个带类型的变量,有名字、默认值、帮助文本,以及一组按位表示的 flags,在定义处一次性声明完毕:
ConVar sv_gravity("sv_gravity", "800", FCVAR_REPLICATED | FCVAR_NOTIFY, "World gravity.");
持久化、归属权、作用域、复制,甚至回放时的如实性:全都是变量自身的属性,在它诞生之处一并声明。没有人需要把 set 绕道经过一个上帝对象,也没有人需要手搓脏数据跟踪。
sv_cheats 之后,ARCHIVE 决定什么能进入 config.cfg——而每一次变更都会被记进 .dem。convar 并不是挂在会话 DOM 旁边的第二个设置数据库。一个会话作用域的 convar,就是权威树中又一个记入日志的节点;它的 flags 声明了它如何参与恢复、回退、spawn、复制与归档。
继承不该需要第二个设置
如今在 omp 里,服务档位(也就是 /fast)为 subagent 单独设了一项设置。
tier:
openai: priority
subagent: inherit # separate setting
在 convar 的世界里,ai_fastmode 就是一个变量,带 SESSION 标志:随会话一起记入日志,所以恢复会话时值也会一并还原。继承根本不需要任何标志:spawn 出来的 subagent 默认会用父级的实时值来初始化每一个变量。没有任何东西需要显式开启。
想让 subagent 固定为某个值?一行搞定:
# subagent.cfg — auto-exec'd for every spawn
ai_fastmode 0
# sonic.cfg — auto-exec'd when a sonic spawns, class config
ai_model @smol
ai_thinking low
主会话用 config.cfg,任意数量的用户 cfg 作为配置档案,每次 spawn 时自动执行 subagent.cfg,再在其上叠加 <agent>.cfg,顺带也解决了那个有一千个属性的上帝对象。TF2 早就知道该怎么走了!
现在一个值就同时描述了主会话和它的 subagent。继承规则住在值被定义的地方,而不是变成日益膨胀的会话上帝对象上的又一个属性。
配置档案与快捷键绑定都留在带内
而一旦有了 cfg,bind 会让事情更上一层楼:bind、toggle 和 alias 同样都是控制台命令,所以我们一直在为之反复发明 schema 的每一种输入模式,都能留在带内(in-band)。用户想要一个隐藏思考过程的快捷键?
bind ctrl+t "cl_showthinking 0" # careful — one-way; the second press still writes 0
bind ctrl+t "toggle cl_showthinking" # there we go; toggle also cycles value lists
alias +thinkhud "cl_showthinking 1" # fires on key-down...
alias -thinkhud "cl_showthinking 0" # ...and on key-up
bind ctrl+h +thinkhud # hold to peek at the thinking stream
我们的快捷键绑定层就该是这个样子:而不是一套自带默认值表的定制 schema!
命令流是把一切串在一起的纽带:cfg 文件、控制台输入、alias、bind、远程管理以及日志回放,全都基于同一批已声明的变量,讲同一种语言。自定义不再催生一个又一个一次性的 schema。
行为:loop 形状的洞
另一个值得关注的话题是可扩展性。我得说,Pi 的扩展层其实相当出色,但它确实有一个“loop”形状的洞。
于是我干脆去装了 Pi 上最流行的 Plan 与 Goal 实现。试着同时激活两者,你会看到:

好吧!有意思,可是并不存在什么“workflow”这样的 API。这是怎么做到的?原来这些实现自己定义了一套:
export const WORKFLOW_MUTEX_CHANNEL = "workflow:mutex:v1";
export const AGENT_WORKFLOW_GROUP = "agent-workflow";
export class WorkflowMutex {
private session: object | undefined;
private readonly heldGroups = new Map<string, WorkflowMutexOwner>();
private generation = 0;
private readonly pi: Pick<ExtensionAPI, "events">;
constructor(pi: Pick<ExtensionAPI, "events">) {
this.pi = pi;
pi.events.on(WORKFLOW_MUTEX_CHANNEL, (payload) => {
this.answer(payload);
});
}
啊哈!两个实现出自同一位作者之手,这位作者早就遇到过这个问题,于是构建了一套在自家那一整套插件之间通用的解决方案。
引入一套系统来封装这种行为的复杂度,就这样被下放给了插件作者,而插件作者只能做出一套仅在自家扩展之间有效的系统。
omp 也有类似的问题:
// modes/interactive-mode.ts — the exclusivity "system", in its entirety
if (this.goalModeEnabled || this.goalModePaused) { this.showWarning("Exit goal mode first."); return; }
if (this.vibeModeEnabled) { this.showWarning("Exit vibe mode first."); return; }
// …restated by hand at six other entry points
只要各自独立编写的行为一碰头,缺失的那层抽象就暴露无遗。一把私有互斥锁能让同一位作者的 Plan 和 Goal 插件互不冲突,却无法让任意扩展彼此组合。omp 手写的模式检查也有同样的局限。
由此得出两个决定:给拥有 loop 的那个原语起个名字,叫 Director;并把更多内置行为搬到公开的扩展面上,让扩展面上的洞再也无法被视而不见。
Director 拥有候选 yield
Agent 有一个 loop。越来越多的东西想要指挥这个 loop:Plan 模式想一直再跑一轮直到计划出炉,Goal 模式想一直再跑一轮直到目标达成,/force 想改动下一次推理,待办提醒则想在我们 yield 之前获得最后一次反对的机会。
那就给Agent 层一个专门拥有这项决策的对象:一个 Director 栈。
这里说的“栈”,指的是会话 DOM 里一棵活生生的子树,而不是一个我们承诺以后再序列化的 Python 数组。DOM 是权威来源;运行时只负责遍历它。
candidate yield flows this way ────────────────────────────────┐
▼
Base → TodoReminder → Goal → Plan → ForceTool(write)
parent child/top
loop 本身依旧无聊得很:
while True:
request = directors.prepare_inference(base_request) # outside → inside
turn = await inference(request)
await execute_tools(turn)
if turn.has_tool_calls:
continue
decision = await directors.on_yield(turn) # inside → outside
match decision:
case Continue(): continue
case Yield(): return
prepare_inference 从外到内遍历这个栈,因此最内层的行为可以在父级即将发出的请求基础上进一步细化。on_yield 则反过来从内往外走。每个 Director 可以:
- Pass(放行)——让下一个 Director 来检视这次候选 yield。
- Continue(继续)——吞下这次 yield,再跑一轮。
- Yield(让出)——吞下它,真正把控制权交还给用户。
- Push(压入)——在自己之上压入一个子 Director。
- Done(完成)——把自己弹出,然后把同一个候选 yield 交给父级。
- Fail(失败)——带着错误弹出。
于是,回退会移除 Director,恢复会把它们还原,远程检视器也能看到当前是哪个行为拥有这次候选 yield。
完整的 Plan 模式
假设 Plan 模式处于激活状态,而模型在没有写出计划文件的情况下就试图 yield。Plan 会先于任何外层行为看到这次候选 yield:
class Plan(Director):
async def on_yield(self, agent, turn):
if not turn.wrote(self.plan_file):
return agent.force_tool(
"write",
until=lambda turn: turn.wrote(self.plan_file),
reminder="Write the plan file before yielding.",
retries=3,
)
if not turn.called("ask") and not turn.proposed_plan():
return agent.force_tool(
"required",
until=lambda turn: turn.called("ask") or turn.proposed_plan(),
reminder="Propose the plan, or ask the user what is missing.",
retries=3,
)
return Yield()
这时 force_tool("write") 在软模式下会压入一个小巧的内置 Director,由它把这项能力注入到下一次推理请求中:
class ForceTool(Director):
def prepare_inference(self, request):
return request.with_tool_choice(self.tool)
async def on_yield(self, agent, turn):
if self.until(turn):
return Done() # pop; offer the yield back to Plan
if self.retries_left:
return Continue(self.reminder)
return Fail("tool requirement exhausted")
栈上 Plan 的下面本来就已经有另一个 Director:
Base → TodoReminder → Plan
候选 yield 会先到达 Plan。只要 Plan 处于激活状态,它要么继续,要么压入一个子 Director,要么直接 yield 给用户。它不会 Pass,所以外层的 TodoReminder 永远看不到这次 yield。
扩展使用的是一模一样的接口:
await agent.direct(VerifyBeforeYield(...))
<directors>
<todo-reminder id="d1">
<plan id="d2" plan-file="local://auth-plan.md">
<force-tool id="d3" tool="write" attempts="1" max-attempts="3"/>
</plan>
</todo-reminder>
</directors>
这是一次完整的组合,而不是又一个特殊模式。Plan 拥有这次 yield,临时压入 ForceTool,在子 Director 完成后重新拿回同一个候选 yield,然后要么继续,要么把它交还给用户。
Hook、Director 与推理
- Hook 观察或修改单次推理或单个 turn。
- Director 能跨 turn 保持控制,并拦截 yield。
- Director 之间可以真正地堆叠、嵌套、结束,并恢复其父级。
这就足以让 Plan、Goal、vibe、autoresearch、提醒以及外部验证这些行为共用同一个 Agent 层原语,而无需让每一个都去了解其他所有行为的私有标志位。
ForceTool 表达的是一个语义层面的请求:“下一个成功的 turn 必须调用 write。”它并不知道所选提供商是否原生支持 tool_choice,不知道强制调用会不会毁掉缓存,也不知道本地模型是否需要额外的提示。这层翻译属于推理层。
现在控制平面能够说出“应该发生什么”了。下一章要做的,是让这个请求在互不兼容的模型与提供商之间都意味着同一件事。
推理
控制平面提出的是语义层面的要求:流式调用这个模型、强制那项能力、约束成这个形状、数一数这些 token。推理层则必须把这些请求翻译成“这个确切的模型、在这个确切的宿主(host)上、通过这个确切的 API”实际能做到的事。
omp 教会我们的:怪癖变成了架构
这一条很好解释,因为 omp v1 上已经有一个现成的前后对照提交。
在 dd57045396 之前,OpenAI 兼容性逻辑全都住在一个 880 行的文件里,围绕着一个巨大的 builder。打开它,迎面而来的是这个:
const isCerebras = modelMatchesHost(hostModel, "cerebras");
const isZai = modelMatchesHost(hostModel, "zai");
const isKimiModel = isKimiModelId(spec.id);
const isMoonshotKimi = isKimiModel && isMoonshotNative;
const isAnthropicModel =
modelMatchesHost(hostModel, "anthropic") ||
isClaudeModelId(spec.id) ||
isAnthropicNamespacedModelId(spec.id);
// …then DeepSeek, Qwen, MiMo, Grok, Mistral, OpenCode, local servers
然后这些布尔值再喂给另一些布尔值、几层嵌套的三元表达式,最后汇成一个巨大的 compat 对象。Kimi 在思考时允许强制工具调用吗?取决于是哪个 Kimi、在哪个宿主上、通过哪个 API。这个回环 URL 指的是 llama.cpp,还是 LiteLLM 在代理别的什么东西?那就再加一条特例吧。
单看任何一个分支,它都没有错!每一条都修复了一个真实的提供商 bug。问题在于,同一份知识最终被编码在了好几个地方:
compat/openai.ts:880 行model-thinking.ts:977 行variant-collapse.ts:1,776 行- 各自独立的 Bedrock、Anthropic 与 Devin 兼容性 builder
- 模型发现与提供商序列化器里还有更多的名字检测
取而代之的是什么?
taxonomy/ "what model is this string?"
classes/ "what is true of this model lineage?"
providers/ "what does this host change?"
于是 Anthropic 的思考配置现在读起来是这样:
class "anthropic" {
on "anthropic" "amazon-bedrock" "google-vertex" {
family "sonnet" {
revision ">=3.7 <4.6" { thinking-mode "budget" }
}
revision ">=4.7" {
thinking-mode "anthropic-adaptive"
}
}
}
这才是我们一直想表达的那份真正的知识!4.6 之前的 Sonnet 版本用预算式思考;Anthropic 4.7+ 用自适应思考;而且只在我们核实过的宿主上才这么声明。
KDL 本身并不神奇。真正把我们从“用更漂亮的格式重建同一堆烂摊子”里救出来的,是编译器:
- 未知的指令或取值?报错。
- 两条同等具体的规则设置同一项?报错,文件顺序不会偷偷胜出。
- 没有匹配的规则?未知,而不是“false”。
这样一来,提供商就没那么怪了吗?当然不。我们仍然有叫 requires-mistral-tool-ids、qwen-preserve-thinking、strip-deepseek-special-tokens 的兼容性轴,还有十种不同写法的“把推理关掉”。看看这些名字,哭一场吧。
它救我们脱离的,是把下一个怪癖再表达成四个不同函数里的又一个分支。现在它是一条规则,放在拥有该事实的地方,一旦优先级有歧义编译器就会冲你大喊,而推理层终于能够回答:这个确切的模型,在这个确切的宿主上,究竟支持什么?
赢的地方不在于怪癖变少了,而在于每个事实只有一个所有者、优先级是显式的、以及当库尚未确立答案时有一个 unknown 状态。harness 的其余部分不再通过提供商名字分支来反复重新发现模型身份。
提供商不只是 stream
从我为 Pi 实现 web-search 插件的那一秒起,这件事几乎注定要回来纠缠我。事实上,同样的压力也砸在了这个仓库极简主义的源头上,看看 Pi 新的图像模型实现就知道了。
Pi 把提供商建模为 stream 和 streamSimple,差不多就这些了!要快速搭起一个提供商这很棒,但要在它上面越垒越多就不那么棒了,因为:
- Anthropic 的 token 计数接口怎么办?
- 或者,Codex 的 WebRTC 语音端点与远程压缩(compaction)?
- 或者,Anthropic/OpenAI 的网页搜索?
- 或者,嵌入向量?
- 或者,图像/视频生成?
- 或者,分词?
- 或者,用量查询?
- 或者,模型发现?
你觉得每一个做了其中某件事的扩展,也都正确实现了同步的 OAuth 刷新与重试吗?
除此之外,能用上推理提供商支持的最前沿控制项是一大胜利,举几个例子:
- 受限采样
- OpenAI 的文本详细程度选项
- Google 的上下文过滤选项
- 强制工具调用
- Developer 角色
- 会话中途的系统提示词
- …
认证刷新、重试、token 计数、搜索、生成、发现以及提供商原生控制项,都是共享基础设施。把它们留给扩展去做,必然得到同一协议的好几份残缺实现。
能力策略:强制工具调用
强制工具调用说明了为什么“支持一个标志位”是不够的:
- 在不支持的提供商上报错: 任何 harness 原生特性都无法使用它,除非把模型清单中的一大部分排除在外。
- 静默丢弃: 调用方拿到一条出乎意料的尽力而为路径,只好自己发明一套强制执行循环。
- 盲目透传: 提供商的副作用变成产品 bug;比如 Anthropic 就可能把强制调用变成整段对话的缓存失效。
- 干脆不暴露: 知情的调用方绕过库自己动手,把上述三种失败模式全部重演一遍。
一个理想的 harness 实现:
- 总是注入一段软提示,告诉模型下一轮必须调用该工具。这值得无条件去做:OpenAI 这类托管 API 会悄悄替你加上这句提醒,但开源推理引擎不会,于是 vLLM 后面的模型会遭遇一个从没人告诉过它的硬约束,在开启推理(reasoning)时手足无措。软提示抹平了这种不对等。
- 只在免费时才设置原生标志位。如果提供商支持无副作用的强制工具调用,就透传。如果它带有代价,就跳过这个标志位,只靠软提示。
- 不服从时升级。如果模型没有调用该工具,就在有限次数内重试;作为最后手段,即便要付出代价也设置原生标志位。劝说失败之后,正确性胜过缓存。
这就是上一章的 Director 在提供商一侧的实现。ForceTool 陈述不变量;推理层选择最便宜且诚实的方式去满足它,并在模型不服从时升级。
工具 schema 是面向模型的协议
工具的 parameters 字段严格定义了它的参数形状。对面向人类的 API 来说这是理想的;但模型不是通用的 API 客户端。它们的错误往往特定于工具名字,以及训练数据里出现过的那些 harness。
被 RL 训练到极致的 Agent 可能会用另一个 harness 的 schema 来调用一个熟悉的工具。Composer 模型有时会按它们预期的形状发出 Grep,哪怕根本不存在 Grep 工具。Codex 看到 paths: string[] 时,可能发来一个用 ; 或 , 分隔的单个字符串,全看当天的心情。
所以库应当既校验又纠正。对工具的语义契约要严格,对模型的方言要宽容:在映射无歧义时把 paths: "a,b" 修复成列表;否则返回一个结构化、可重试的错误。一个原始的 JSON Schema 校验器无法独自扛起这一层。
严格采样需要预算与方言
受限采样是我们最早给 Pi 加上的功能之一:
+ strict?: boolean;
+ customFormat?: { syntax: "lark" | "regex"; definition: string };
+ customWireName?: string;
几个月后 Pi 跟进了 LARK 与严格模式支持,但只把它暴露为一个不透明的结构,交由提供商层透传。两个全局性约束让这种做法不够用:
- 严格 schema 的容量是一份共享预算。 许多提供商会限制严格 schema 的数量。足够多各自独立编写的扩展,就能让提供商拒绝每一个请求。用户不应该靠二分排查、逐个给插件打补丁来救回 harness。
- 语法(grammar)方言是提供商特定的。 把一份 LARK 语法传给每个提供商,这件事本身就可能是无效的。扩展无法维护这份兼容性映射,因为用户可能把同一个模型经由原生宿主、代理或自定义提供商来路由。
这就是为什么那个看起来“复杂”的实现属于推理层:
strict 需要什么:提供商能力判定、带优先级的严格 schema 预算、按方言归一化,以及客户端修复路径——这些都不是一个不透明的透传结构体所能提供的。扩展声明意图:严格性、语法、优先级。推理层则拥有能力判定、预算、方言归一化、回退、修复以及最终的传输格式。
纠错式推理
推理库还需要:
- 修复格式错误的 JSON;
- 检测 Gemini、DeepSeek 等模型中的重复循环;
- 解析每个模型的输出方言,并在结构化输出泄漏到文本里时合成规范的
tool_call和think块。

一段泄漏的工具调用被当作散文渲染出来,因为方言没有被解析成 tool_call 块。
关于这个话题的工具调用一侧,可以读我之前的文章。支持一个提供商或模型,除了接上一个 URL,还得处理它各自的怪癖。
一个提供商适配器并不是能打开一条流就算完成。当 harness 的其余部分在遇到格式错误的 JSON、重复、泄漏的推理或模型特有的工具调用方言时仍能收到一个规范的 turn,它才算完成。
压缩是调度出来的,而不是触发出来的
朴素设计在这里恰好也给出了最糟的用户体验。用户要在自己投入最深的那一刻,等待整个会话中最大的一次请求。
除了使用 Snapcompact 这类方法之外,这里仍有大量改进空间。

连前沿实验室交付的也是朴素设计。
正确的做法是:在距离上限还差约 10% 的时候,就推测性地启动压缩过程。本质上你把对话分叉成两个并发版本,一个里面用户和模型继续工作;另一个里面模型在压缩对话。

看到那个图层图标了吗?它显示的就是推测性压缩触发的时机。
收到响应后,你再把它拼接进另一个分支。这也让你保住了工作的势头,因为模型不会被历史中孤零零只剩一条交接(handoff)消息的局面搞糊涂,而是会看到它本来就应该已经做完的全部进展。
除了提示词之外,其他值得考虑的方法还包括:
- 远程压缩:由提供商在服务端完成。OpenAI 的 API 会返回一个不透明的状态 blob 作为结果,但由于它能访问解密后的思考内容,可以显著减少上下文的损失。
- 交接:与其索要一份摘要,不如试试让模型把工作“交接”出去。
- 抖落(Shake):完全在本地进行,你可以直接把历史里沉重的工具结果裁掉。
请注意,这也是你在“UI 渲染 vs. 请求渲染”这层抽象里应当考虑的事情:用户查看历史时会期望所有消息原样保留,而对模型来说,那些消息一条都不存在;因此在构造请求 fn(this, req) -> req 时,你应当把提示词历史中的每一条记录建模为一次“折叠”,并在它的 <Handoff> 实现中处理。
用小型本地模型做 harness 的杂活
小型本地模型超级有用!哪怕你真的只跟前沿模型打交道,我也建议你内嵌一个 tiny 模型(尤其看看 LiquidAI 的模型),这会在分类任务上为你省下大量延迟和金钱,也适用于生成标题、翻译,或者判断用户对对话走向有多满意这类小任务。另一个用例当然是 TTS/STT,在本地你已经能拿到 SoTA 级别的表现。
这不是第二个“Agent”。它是一种廉价的内部能力,用于那些不该付出前沿模型延迟或成本的小任务。
一旦兼容性与修复被集中起来,常驻工具面就可以保持小巧。下一章要讲的是什么值得在每个请求里都带上 schema,以及什么坚决不值得。
工具面
运行时一章定义了工作如何执行。推理一章定义了 schema 如何在不同模型与提供商之间存活下来。现在终于可以问那个产品层面的问题了:哪些操作配得上占据模型的常驻语法(grammar)?
每个 schema 都有代价
把大多数工具呈现给模型的最佳方式,是根本不把它们放进常驻工具清单。
前阵子我收到一条抱怨,说 omp 在同一个任务上比 Codex 慢,不是 token 用量上的慢,而是实打实的挂钟时间。我本以为这纯属虚惊一场,结果让我意外的是,这是真的,甚至差了将近两倍!
sol,6 次运行取中位数,每次都是全新会话 · 青色 = omp 各变体,灰色 = 外部参照 · 标注为相对上一行的差值。罪魁祸首就是工具清单。把它限制在五个核心工具,你就能拿到 36.6s,领先于 Codex 的 42.2s 和 Pi 的 37.0s。为什么?工具语法!哪怕对模型来说它只是一段文本描述,在大多数前沿模型提供商那里,它都会实实在在地参与 token 生成,因为它影响着 token 生成过程,驱使模型始终给你输出合法的 JSON(这还不算用来描述它的那些 token)。
工具不是什么免费的好处,不能抱着“万一模型用得上呢”的心态随手往里加;动态工具发现的思路正源于此。但这种动态方式有个问题:只要你一改工具清单,缓存就失效了,所以我们不太喜欢它。
Pi 有一点做对了,我们也一直认同:MCP 的设计糟糕透顶,不该待在常驻工具层里。那么,我们该如何同时满足想要 Figma MCP 的用户和推理层的约束呢?
动态工具发现避开了常驻语法的成本,却在清单一变就让缓存失效。更好的目标是:一套稳定、精简的语法,外加一条通过普通组合就能触达的长尾。
把长尾放到稳定的接口后面
来认识一下 dyn CLI!它当然不是真正的 CLI,而是我们的 Bash 实现暴露出的一个内置工具,给模型提供一套稳定的发现协议,以及一种顺手的使用方式:通过 Bash 调用,或者在 Eval 里当作 Python 函数调用。
dyn
dyn --q github
dyn github/list_prs --state open | jq '.[] | .title'
cat query.sql | dyn database/query - --params limit=5
dyn image_gen "blueprint of a frog" > result.json
一旦模型找到了感兴趣的工具,就像工具搜索那样,它可以用 --help 获取详情:
$ dyn github/create_pr --help
dyn github/create_pr <title> [OPTIONS]
Arguments:
<title>
Options:
-d, --draft / --no-draft
-r, --reviewers <TEXT>[,…] (repeatable)
-p, --pr-meta.priority <INTEGER>
-m, --pr-meta.notify / --no-pr-meta.notify
-j, --json <JSON>
-h, --help
这当然是从 JSON schema 合成出来的,而要生成一份漂亮的 CLI 映射,有 schema 就已经足够了。
在处理大输入时,这套方案尤其好用:
dyn database/query "SELECT 1" # literal
dyn database/query @query.sql # file contents
cat query.sql | dyn database/query - # stdin
有一个边界情况需要处理:返回图片的工具怎么办?想想看,omp 是怎么把图片显示给你的?Sixel 或者 Kitty 协议,对吧?那为什么不在 Bash 工具里解析同样的输出,然后把图片附上去呢!这样一来,你还能通过 ssh 查看远程机器上的图片,美滋滋。
当所有这些操作都属于同一个 API 时,还有第二种选择:暴露一个代码面。Browser 保留 open / run / close,针对一个持久标签页运行代码;Computer 则在一个持久会话中暴露 desktop、wait 和 assert。一个稳定的 schema,多个操作在一次调用内组合。有界的操作集:schema。开放式的操作集:代码面。
这两种形式服务于形态不同的 API。有界的操作集可以继续用 schema;开放式的操作集则需要一个代码面或命令面,让多个操作在一次调用内组合起来。两者在发现之后都不需要改动常驻清单。
契约卫生:意图与版本
契约上有一处小改动值得单独点出来:每个工具都会得到一个 i 意图参数。它在参数流式到达的过程中就会先到,所以 renderCall 能在调用完成之前就展示模型自认为正在做什么。日志也因此得到一份可读的摘要,而无需每个工具各自发明 reason / purpose。
大家应该给工具加上版本。
这会让追踪记录(trace)好用得多:你可以解析一个频繁变更的工具的输入输出,评估它的成功率随时间的变化,而不必猜每次调用是由哪一版契约产生的。
名称、版本、意图、输入、输出、诊断信息和用量都是协议数据。一旦追踪记录被用于评估或修复,对其中任何一项靠猜,都会变成本可避免的技术债。
深度内置工具
精简的清单只有在一种情况下才行得通:它的原语之所以宽泛,是出于语义上的理由,而不是因为一堆不相干的功能被倒进了同一个 switch 语句。omp 的内置工具是很好的例子。
Read:物化一个资源
omp 里最无聊的工具,实际上打包了在别家得用 20 个工具才能做到的事。
- 你可以直接读目录,不需要
Ls。 - 不需要额外的
ReadNotebook工具,读一个.ipynb文件时默认就能得到漂亮的输出。 .pdf、.docx、.pptx、.xlsx、.epub?你拿到的是提取出来的 markdown。.cpuprofil, .sample.txt?你猜对了!你拿到的是一份瓶颈摘要。.sqlite、.sqlite3、.db、.db3?你可以列出表、查看 schema 和行,甚至直接查询。- 图片要么返回图片本身,要么在没有视觉能力时返回元数据。要预览 SVG,加上
:img。 - 归档文件无需解包即可寻址,不只是 ZIP 和 TAR,还包括 JAR、wheel 和 ASAR。
- 同样的投影对
http://...上的在线资源也适用,范围按需读取;普通网页会变成 markdown,就像web_fetch一样。
这并不是为了耍聪明而搞的多态。从模型的视角看,这些全都是同一个操作:
把这个资源物化成我最便于推理的表示形式。
对于代码,它还能返回一份结构摘要,用省略号替代大段的声明体。模型不必仅仅为了找到类 X 就把整个大文件拉进上下文。
当字节本身才重要时,:raw 可以绕过投影。:conflicts 则为每个未解决的合并冲突块给出一行,而不是让模型在整个文件里翻找。
范围可以是开放式的、按长度指定的,或者不连续的:
:50
:50-
:50-200
:50+150
:5-16,960-973
:raw:50-100
:50-100:raw
然后还有那些非 web 的 URL:
artifact://<id>
agent://<id>
history://<id>
issue://123
pr://123/diff/2
skill://react
rule://foo
memory://...
local://...
vault://...
security://...
omp://...
xd://browser
ssh://host/path
mcp://...
仓库信息、MCP 资源、subagent 的 transcript、技能、记忆、本地暂存空间、omp 文档,甚至通过 SSH 访问的远程机器,全都纳入同一套内部 URL 子系统。我们推荐这种设计。
Read 还处理一些不那么显眼的恢复工作:根据唯一的工作区后缀修正错误的绝对路径、在 Windows 上展开 ~,以及避免其他浪费 turn 的路径错误。
这本可以就写成:
return await Bun.file(path).text();
可以。但那样一来,扩展作者就会各自实现自己的读取器,或者模型会去找 shell 层面的变通办法,与此同时 harness 又以 web_fetch 之类的独立名字暴露着形态相似的功能。
这并没有减少复杂度。复杂度还是那么多,只是被复制进了 shell 命令、提示词、扩展和失败的工具调用里,在那里没人对它负责,而每个人都用略有不同的方式实现了其中的 30%。
Read 很复杂,所以读取本身不复杂。
复杂度有唯一的归属者。操作保持稳定,而资源特定的投影挪到了它背后。
Bash:一门策略感知的命令语言
Bash 工具不应该只是简单地把命令扔给 Bash 去跑。这听起来很离谱。
omp 自带一套完整的 bash 解析器、解释器,外加一整套 coreutils,全部在进程内运行;事实证明这是个不错的选择,理由很简单:
- 你保住了模型的肌肉记忆。它可以照旧伸手去用
grep;因为 omp 就是解释器,我们可以拦截这条命令,把合适的参数路由到我们的 ripgrep 引擎。没人需要在AGENTS.md里花费上下文求模型用rg。 - 平台中立几乎是白送的。不需要 WSL 或 Git Bash:omp 可以在 Windows 上于进程内执行大多数 Bash 调用。无需多言。
- 控制台在多次调用之间保持有状态,包括变量、退出码、
$!等等。
更有意思的优势出现在 Claude 用下面这种东西调用它的时候:
INC="…/10.0.22621.0"; declare -A R
for d in um shared ucrt; do while IFS= read -r f; do b="${f##*/}"; R["${b,,}"]="$f"; done \
< <(find "$INC/$d" -maxdepth 1 -type f -name "*.[hH]"); done
n=0
while IFS= read -r ref; do case "$ref" in */*) continue;; esac; r="${R[${ref,,}]:-}"; \
[ -n "$r" ] || continue; rd="${r%/*}"; rn="${r##*/}"; \
if [ "$ref" != "$rn" ] && [ ! -e "$rd/$ref" ]; then ln -s "$rn" "$rd/$ref"; n=$((n+1)); fi; \
done < <(grep -rhoiE "#[[:space:]]*include[[:space:]]*<[^>]+>" "$INC/um" "$INC/shared" "$INC/ucrt" \
| sed -E "s/.*<([^>]+)>.*/\1/" | sort -u)
你能在 5 秒内告诉我这段在干什么吗?(如果你说能,那你在撒谎)
无论你对工具审批持什么看法,这都很糟糕:没人会去读它。Anthropic 最近的研究也指向同一个方向:自动模式,也就是让另一个 Claude 来读命令,以相当大的优势胜过了人类。
当 omp 自己来解释这条命令时,它可以等执行到 ln 的那一刻再发问;在那之前的一切都是只读的。如果用户已经允许对该目录写入,它甚至连这一次提示都可以跳过。
这让 harness 从“Bash”的 TSA 式安检口,变成了一个能力审批者:“我可以用 Git 推送吗?”find、cat、ln 这类常见命令在进程内运行,恰在需要时查询访问模型,并继承用户已有的读写策略。
因为宿主自己解释常见命令,审批可以发生在真正要紧的能力边界上,比如 git push、工作区之外的写入、一次网络请求,而不是发生在那个没法读懂的 shell 字符串边界上。第三章的运行时策略由此变得可以强制执行,同时不必丢掉模型的 shell 肌肉记忆。
AutoQA:给 Agent 一条报告 bug 的路径
我们在分叉一个月后就加了这个工具,比 Anthropic 往他们的产品里加等价物还早。
你知道,通常你都会在某个地方给用户提供一条反馈产品问题的渠道,对吧?这就是那东西的等价物,只不过是给 Agent 用的。它让你能完全自动地收集信息:它们喜欢某个工具的哪些地方、觉得哪里令人困惑,以及看到哪里表现出错。
当然,报告的质量算不上出色,比如 Codex 就特别爱在重命名没做对的时候抱怨文件被外部修改,把锅甩给 Read 或 LSP 工具(不是我的错啊哥们,去问 TypeScript 那帮人),不过这些很容易过滤掉,过滤之后,你就能获得海量信号:哪个工具会失败,以及它可以如何改进。
AutoQA 闭合了工具设计与实际部署行为之间的回路。它有噪声,但一旦过滤掉明显的错误归因,它就能揭示哪个操作让模型犯糊涂、哪个投影藏掉了需要的数据、哪项修复应该落在 harness 里。
工具现在有了有界的运行时、稳定的发现面和结构化的状态。用户不应该要求每一位工具作者(往往就是 Claude)都得成为终端渲染和安全专家,仅仅为了把这些状态安全地展示出来。
界面
267s → 90ms:单次会话的渲染时间
13%:性能剖析中一个 .includes 占用的 CPU
98.7s:耗在 wrapAnsi 反复折行上
0:该会话中的图片数量
会话 DOM 与工具状态流让每个客户端拿到的都是同一组事实。但仅凭这些,并不能自动得到一个安全、快速、一致的界面。渲染器仍然可以把这些事实变成反复重新解析的字符串、各扩展自成一派的样式约定,以及不可逆的 scrollback bug。
omp 教会我们的:字符串的代价会层层累加
这其实正是我给 pi-mono 提的最早几个 PR 之一的主题。在那次改动之前,如果你在一个任务执行期间对 Pi 做性能剖析,再去看 CPU 占用,榜单会被——你猜对了——渲染器完全占满!
作为一个 TypeScript CLI,这里有一部分开销在所难免(光是字符串内部采用 UTF-16 这一点,就意味着每一帧都得经过一次相对昂贵的转码,除非你像疯子一样到处传 Uint8Array 来表示文本)。
但真正让这笔开销层层累加的,是契约本身。你想嵌入一个子组件?那你就得处理:
- 对这个
string做净化,并丢弃 ANSI 转义序列或在解码时跳过它们 - 对每一行做填充、截断和宽度计算
雪上加霜的是,图片也可以作为 base64 文本,混在这些行里传递。仅仅是用 .includes 检查某一行是不是图片行,就占掉了一次会话全部 CPU 周期的 20%。这账单可不便宜(而且这个会话里压根一张图片都没有!)。
这还只是 JS 这一侧的图。在这种设置下,渲染管线就是一台不停折腾堆内存的机器:你不断地分配、拆解、丢弃字符串和字符串数组——拼接、切分、截断、填充,每一步都在反复来一遍。Not gud.
同一份契约还让扩展之间没有共同的设计语言。只要你用过任何一个 Pi 扩展就知道,除了让 Clawd 把每个扩展逐一重新调样式——然后自己维护那份结果——之外,根本没办法让它们遵循同一套规范。
没有任何契约规定:该不该用圆角边框、能不能用 Nerd Font 图标、它会不会用你喜欢的颜色来表达自己正在做的事情的语义。你会发现:
- 99% 的情况下,它只会做最低限度的事(也就是截断/折行文本),你所有的工具都成了一模一样、无法区分的灰色矩形。
- 1% 的情况下,它又拼命想显得花哨,结果在你整体极简的配置里显得格格不入。
Pi 目录里的一个社区渲染器,展示了这份契约对最终呈现给用户的东西做了什么:
if (cq.sources.length > 0) {
lines.push("");
for (const s of cq.sources) {
const domain = s.url.replace(/^https?:\/\//, "").replace(/\/.*$/, "");
const title = s.title.length > 50 ? s.title.slice(0, 47) + "..." : s.title;
lines.push(theme.fg("muted", ` \u25b8 ${title}`) + theme.fg("dim", ` \u00b7 ${domain}`));
}
}
lines.push("");
} else {
const textContent = result.content.find((c) => c.type === "text")?.text || "";
const preview = textContent.length > 500 ? textContent.slice(0, 500) + "..." : textContent;
for (const line of preview.split("\n")) lines.push(theme.fg("dim", line));
}
if (details?.fetchUrls?.length) {
if (details.curated) {
lines.push(theme.fg("muted", `Fetching ${details.fetchUrls.length} URLs in background`));
} else {
lines.push(theme.fg("muted", "Fetching:"));
for (const u of details.fetchUrls.slice(0, 5)) {
const display = u.length > 60 ? u.slice(0, 57) + "..." : u;
lines.push(theme.fg("dim", " " + display));
}
if (details.fetchUrls.length > 5) lines.push(theme.fg("dim", ` ... and ${details.fetchUrls.length - 5} more`));
}
}
这里的问题可不少:
- 它按码点而不是可见宽度来切片文本,所以一旦你把终端调到 40 列以下,它就会冲出自己那一行,把下面的一切都撞得稀烂
- 它完全不知道终端宽度,所以哪怕空间足够,你也照样会看到省略号!
- 最重要的是,它无视 Pi 组件的第一条规则,不对外部输入做净化。这意味着它抓取的那个东西只要喂给它合适的 ANSI 转义序列,就能把你的整个 UI 换成一张鸭子的图片。绝对没法拿它干别的了!
当你把复杂度一股脑推给毫无防备的开发者——而这个开发者往往是 Claude——出现这种事再自然不过。
每次被要求“做个工具 UI 吧”的时候,LLM 是不会记得你 harness 的每一个内部细节的。说实话,有时候我自己也不想记,而冒烟测试一跑通就算“能用”了。
性能、安全和一致性这三类问题同出一源:一个已经渲染好的字符串,被同时当作布局树、样式树、内容、传输载体和终端程序来用。
omp² 的改变:一次通过的原语
最底层的消费者(也就是说,不是你,除非你来提 PR)把 RichText (Style, String) 推进交到它们手里的抽象管线 (&mut impl Out) 中。
这把 267 秒的渲染时间砍到了 90ms:
render(): string[]——N 个组件 × M 次变换,每一帧都把每个缓冲区重新解析、重新测量、重新分配。之后:RichText 片段一次通过抽象管线,直接流入帧 diff。临时对象、ANSI 解析、字素处理:在帧渲染器以下的每一层里,统统消失了,这不是显而易见的嘛!
既然我们可以直接……流式输出填充,再输出你的一行,如此重复,那为什么还要先给你的组件加好填充再往下传?既然我们可以直接……在省略号之后丢掉你的流,或者在变换过程中由我们自己把它拆成行,那为什么要让你先把一份 255 行的 diff 全彩渲染出来,再 .slice(0, 3) 截断成另一个字符串缓冲区数组?
底层原语只做一次测量与变换,并对此全权负责。更高的层次永远不该去解析 ANSI,来重新发现它们自己发出的结构。
类型化的组件模型
接下来,string[] 会被一个像样的组件模型取代。更高层的消费者只需要把盒子一层层堆起来,享受 LSP 一路指引:

标记是类型化的:在

标记进,帧出:
说起来,我可能不喜欢搞前端,但我真的太喜欢一个好的抽象了。(Element, Props, Children) 再配上一个布局引擎,就真的是你所需要的全部了,相比之下简直美妙。
DOM 那一章承诺过,一个工具元素可以由任何 actor 来渲染。下面就是这个承诺的具体形态:
这就是 Read 组件的样子。还不赖,对吧?
<box bc=muted>
<row kind=title gap=1>
<text>•</text>
<text bold>Read</text>
<a href={input.path}>{input.label}</a>
{#if status=error}<badge tone=error>exit {code}</badge>{/if}
</row>
{#if result.head}<pre lang={result.lang} wrap=word start={result.start}>{result.head}</pre>{/if}
{#if @expanded}
{#if result.blob}<pre lang={result.lang} numbers start={result.start} blob={result.blob}></pre>{/if}
{/if}
{#each diag as d}<callout tone={d.severity}>{d.msg}</callout>{/each}
{#if result.src}
<hr title="Output"/>
<row gap=1 fg=muted>
<text>⟨Resolved path:</text>
<text>{result.src}⟩</text>
</row>
{/if}
{@render usage}
</box>
工具作者描述结构和语义。TUI、Web 客户端、快照测试和远程检视器各自决定这份结构在自己的表面上如何布局。
表现策略归渲染器所有
组件模型白送了两个有用的特性:
<ico:new/>给每个插件一个顺手的图标,同时尊重用户在 ASCII、Unicode 或 Nerd Font 之间的选择。边框也是同样的机制。- 语义颜色不再需要把一个主题对象穿过每一个渲染器。Claude 可以直接要
info,而不用挑一个具体的颜色值,然后祈祷它和用户的主题搭得上。

border=round bc=“info” 会解析为主题的语义颜色;fg=“red..blue” 则是一段渐变。没有任何地方需要穿一个主题对象。
你还需要掌控文本流的节奏。Claude 和 Codex 吐出分块的节律截然不同——一个一次几个词,另一个一次几个字符。抹平这些差异会改变 harness 给人的响应感:平稳的运动读起来像是在推进;一阵爆发接着一段卡顿则不然。Heh.
语义图标、边框、颜色、截断和流节奏现在都只有一个归属者。扩展只管要 info、error 或 <ico:new/>;它们不会把主题对象穿过每一个函数,也不会替每个用户挑选 Nerd Font 字形。
验证是界面的一部分
在当前的“meta”(主流打法)里,投入产出比最高、而且不花你一分钱的投资,就是让 Agent 为任何交互式 TUI / GUI 实现一套调试协议。如果“如何验证”既未知又未定义,Agent 就会另辟蹊径弄出一个看起来像那么回事的替代品,也就是说,它会写一个大多数情况下什么都没真正检查的测试文件。
提前定义好“验证”意味着什么,并给它一个顺手的形态,就能大幅降低摩擦,这意味着它会成为开发循环中一个活跃的环节。

形态本身其实不重要,而且随时可以更新:它可以是一个自定义工具、一个 Python 包或者一个 API,但绝对必须提供一个非破坏性的、离屏的、可多实例的东西,用来阻止 Agent 重新定义(通常是降低标准地重新定义)什么叫成功。
换句话说,调试协议成了“UI 是什么”的机器可读定义——而不只是一个测试辅助工具。
Transcript 是一个协议
TUI 真正不可能做到的部分,是做到没有一条 GH issue 抱怨它坏了。人们对自己不了解的东西总是理想主义的,而不幸的是,很多人并不知道他们想要的那种完美 TUI 体验根本不可能实现(每个组件无论位于何处都完全保持最新,还能动态变更)。
块
我们把规范的 transcript 定义为一个块列表。一个块产出若干行文本,并经历一个生命周期:
活跃 → 已定稿 → 已提交
存活期间,块 i 展示一个当前快照 Wi,它是一个行数组。定稿时,它冻结为一个不可变快照 Fi。
块有两种模式:
- 可变:每个新快照都可以整体替换前一个(旋转指示器、进度条)。快照是推测性的,永远不会成为历史;只有 Fi 会。
- 仅追加:快照只增不减:每个快照都是下一个快照的前缀,最后一个快照是 Fi 的前缀(流式文本)。
当一个块超出了分配给它的视口空间时,这个区别就很重要。可变快照不能提前进入历史,因为后续更新可能会替换它;那样我们就得把已经滚上去的行硬拽回来。而像助手思考这样的仅追加块只会延长一个稳定前缀,所以这个前缀可以立即开始提交。
终端
宽 W、高 H 的终端有两个缓冲区:
- V:视口,有 H 行可见
- S:原生 scrollback,无界,仅追加
技术上,我们可以清空并覆写 scrollback,但这会导致用户经常抱怨的那种行为;所以它现在是一条不变量。
折行 wrapW 把逻辑行变成物理行,并取决于当前宽度。视口下方没有可寻址的区域。写过它的底部会让终端滚动,把顶部的行不可逆地推进 S。
逻辑历史 L 以未折行的行来保存,因此与宽度无关:按块顺序排列的已提交定稿,每个恰好出现一次,再加上当前正在流式输出的块中已经放行的那部分。设 c 为最后一个已提交的块,j = c+1:
L = F1 · F2 ⋯ Fc · Wj[1..ej]
其中 ej 是流式头部已经发进历史的行数(除非块 j 是一个正在流式输出中的仅追加块,否则 ej = 0)。
因此:
- 已提交的定稿恰好出现一次,连续,且按块顺序排列;
- 可变的推测性快照永远不会进入 L;
- 仅追加的头部可以在仍在流式输出时逐行进入 L;
- 定稿不写入任何东西;
- 提交只追加 Fj 中尚未发出的那些行
调整尺寸
调整尺寸(resize)不改变任何逻辑层面的东西:每个 Wi、每个 Fi 和 c 都原封不动地保留下来。只有折行和视口分配会被重新计算。已经进入原生 scrollback 的行无法重写,所以调整尺寸需要为它们指定一条明确的策略:
- 保留:原样保留终端模拟器折行后的历史。
- 追加:追加一份重新渲染的历史,物理行可能会重复。
- 重建:开启一个新的物理纪元,把历史回放进去。
这些规则把三件容易混为一谈的事情分开了:视口中可变的表现、与宽度无关的逻辑历史,以及不可逆的原生终端行。一旦给它们起了名字,调整尺寸和流式输出就变成了策略选择,而不是口口相传的经验之谈。
为不可能的部分写规约
那么,我为什么要拉着你过一遍这些“数学”?因为要验证这个算法是否正常,是件非常复杂的事;上一代实现里,我们不得不写一个 fuzzer 才达到稳定状态,这一次我想避免这种事。
取而代之的是,我们按上面描述的方式用 TLA+ 对这套行为建了模,然后要求对这些块的提交与定稿处理方式做迭代修改,直到清晰定义的不变量全部满足为止。
现在,如果我们真想改点什么,比如说,yolo 式地提交部分内容,或者不允许块截断,我们就有了一份可以更新的参考规约,以及一种极其简单的方式来判断它能不能行得通,失败时还会给出反例。
论文和完整的 ElasticSlots.tla 源码放在附录 B。
这解锁了什么
例行秀一下肌肉,然后我们就可以往下走了!现在要是有人抱怨 TUI 坏了,我可以直接给对方一份形式化证明,说明它为什么不可能修好,棒极了。

任务进行中的 omp² TUI:命令面板覆盖在实时并行分片之上,会话侧栏带有逐文件的 diff 统计,还有一张内联图片缩略图——每个元素都是同一条流式管线上的组件。
TUI、Web 客户端和远程检视器可以在布局上各不相同,而在事实上完全一致。工具作者描述语义状态;组件系统掌管表现;transcript 协议掌管恰好一次的历史。
这又是同一个设计动作的再次重复:把难啃的不变量下推到能强制执行它的那一层。实现技术栈应当加固这些不变量,而不是引诱每一个贡献者——以及每一个coding agent——去发明一套自己的局部风格。
技术栈
前面几章讲的是架构。而语言的选择,决定了代码库会在这套架构与下一个“善意的”局部例外之间设置多少摩擦。当实现中的很大一部分由 Agent 产出,而这些 Agent 又是在各个生态的默认做法与病态习惯上训练出来的,这一点就更加要紧。
语言选择即架构
眼下,除非你别无选择、必须和前端代码打交道,否则 TypeScript 是一个糟糕的选择。
现在启动一个项目时,你能做的最有影响力的决定之一就是:选对工具。要是三年前我看到一篇文章这样开头,肯定已经开骂了,但是……如果你不信我,试试把描述同一个小组件的同一段提示词交给 Claude。
然后把 macOS(Swift)换成 Linux(Qt/JS)。前者会给你一个毛玻璃风格、看上去就像系统自带的小组件;后者则给你一个 UI 元素互相重叠、UX 取舍令人生疑的矩形,让你感觉自己刚啃完定义 UI 所需的那套 XML schema,而这是你第一次把它编译出来。
当然,你怎么写提示词确实有影响,你也确实可以描述得更细,但用不了多久你就会发现,无论你怎么做,其中一方几乎毫不费力就能胜过另一方。macOS 历来做得好的一件事,就是强迫开发者遵循同一种一致的设计风格,而这对 LLM 来说同样成立。
重点不在于 Swift 有品味而 JavaScript 没有。而在于默认值、标准库、规范的项目形态、编译器反馈以及生态惯例,共同构成了生成代码的先验。一门允许二十种同样“正常”的局部风格并存的语言,等于要求模型在触及产品问题之前先做二十个决定。
TypeScript 会变成你自己的语言
不幸的是,我曾经最喜欢 TypeScript 的那一点,恰恰是它最终总会变成你的语言:
- 用
camelCase还是snake_case?或者干脆把你的库命名为$? - 写横跨 200 行的泛型,还是一个泛型都不写?
- 用
Buffer还是Uint8Array? - 用 Zod 还是 Typebox?
- 用
Array<T>还是T[]? - 用 ESM 还是 CJS?(那扩展名呢?
.ejs, .cjs, .mjs, .js?) - 用 TypeScript 还是 JSDoc?
- 用 Class,还是继续用对象(或者干脆 new function())?
- 默认导出还是不默认导出?
- 用星号重导出,还是逐个点名?
- 用
private foo还是#foo? - 用
module/index.ts还是module.ts? - 用
const x = () => ..还是function x() {? - 用
function x(args)还是function x(...args)? - 如果选后者,用
...args: any[]还是...args: unknown[]? - 用
const X = 1、enum E { X = 1 },还是const enum E { X = 1 }?
你看,我在那门最大的“只写不读”语言(也就是 C++)上耗了十年人生,对这种事确实乐在其中。可当被迫在 Zod 和 Typebox 之间二选一时,你那位初级小伙伴只会随手撸一个我们所谓的 isRecord。能直接把类型 union 起来,何必用泛型?能用一点 typeof 做特化,何必费脑子确保每个分支对两种类型都成立?何必用类,不就是对象加原型嘛,不是吗?
也许是因为外面烂 JS 代码实在太多,也许是因为它们一路上多半吞下了一堆压缩过的代码,反正我受够了。考虑到同一个初级小伙伴能找出 Linux 0-day,换作是我,就不会再指望正确的模型或正确的代码质量工具了,也别再费劲钻那些圈子了。
也许 EffectJS 会改变这一局面;但在我看来,最终赢家会是 Go(尤其是等 WASM 的 GC 提案定稿之后),理由与 Swift 在设计上胜出的理由类似(尤其是编译速度和交叉编译的便利性)。不过有些场景需要更底层的系统语言,所以我们这里选了 Rust。
它们仍然需要相当频繁地被纠偏,因为它们总走通往目标的最短路径:宁可分配副本也不去处理精细的借用,把错误当字符串传递而不用 thiserror;但它们工作所需的大部分东西都在 std 加上 serde 生态里了,而且编译器提供了相当程度的安全保障,所以就这么定了。
用 Python 做扩展
下一个决定是:要不要为了可扩展性把 TS 请回来。我们说不,主要因为:
- Agent 能写出像样的 Py => 顺理成章,扩展也就像样
- 想在小体积下实现一个符合规范的 JS 运行时 基本不可能(谢谢你,Locale),而没有生态的话,我们还不如直接跑 Lua
- 扩展连运行时间的 1% 都占不到,所以我们其实不需要 JIT
- 内嵌一个完整的 Py 运行时之后,我们还能保证
eval工具开箱即用,而不是要求用户自己安装 py3,然后在我们发布的流程里永远没法指望它 - Python 代码开箱就能审视自己的 AST。正是这一点让运行时那一章的
@remote设计成为可能。
运行时那一章引入了 @remote 边界。Python 的自省能力和属性模型正是让这条边界用起来顺手的原因:SDK 可以审视一个函数、打包相关源码,并在沙箱运行时里执行它,而不必要求每位扩展作者手写一套 RPC。
自带运行时也让 Eval 成为一个可靠的内置工具,而不是只有在用户恰好装了兼容版本 Python 时才能用的功能。
结语
“可是为什么?”是开篇的问题。直接的回答是:上面每一章都对应着一个有几十年先例可循的软件门类:复制、沙箱、配置、调度、协议兼容、实时渲染,以及语言/运行时设计。
omp² 仍在对照这份文档构建之中,各部分的状态从已交付到仍在思考不等;但我们真诚感谢每一位试用过它的人,感谢你们与我们分享各种精彩的 omp 用法:从让它运营一座软件工厂,到让它在它自己所在的那部手机上给自己造一个相机 app。
是你们塑造了 omp,我们期待未来同样精彩!
附录 A:官方示例中的状态故障
状态那一章按类别总结了这些故障。本附录保留原始证据:源码链接、最小代码摘录和复现视频。
这一论断并非纸上谈兵。我们查看了 78 个官方扩展示例:60 个是无状态的;在 17 个带状态的示例里,只有两个是正确的。
1. 检查点在 /fork 能用上它之前就被清空了:git-checkpoint.ts
源码:缺少持久的检查点所有权;/fork 在空闲时被调用,而此时 agent_settled 早已清空了唯一存放 stash 引用的 map。
const checkpoints = new Map<string, string>();
// …
pi.on("agent_settled", async () => {
checkpoints.clear();
});
2. 树导航不会恢复状态:plan-mode/index.ts
源码:缺少 session_tree 和 getBranch();回退之后 Plan 模式及其工具限制仍然生效,而恢复则可能让一条死分支的快照复活。
const entries = ctx.sessionManager.getEntries();
const planModeEntry = entries
.filter((e) => e.type === "custom" && e.customType === "plan-mode")
.pop();
3. 计数器数不清历史:status-line.ts
源码:缺少分支推导;从第 3 轮回退到第 1 轮,下一轮却显示 4,而恢复后又从零开始数。
let turnCount = 0;
// …
pi.on("turn_start", async (_event, ctx) => {
turnCount++;
4. 动态添加的工具挺过了回退,却在恢复后消失:dynamic-tools.ts
源码:/add-echo-tool echo_branch 只写入活跃的扩展注册表;/tree 不会重启这个注册表,所以回退后工具还在,但 --continue 会新建一个注册表,工具就消失了。
const registeredToolNames = new Set<string>();
// …
registeredToolNames.add(name);
pi.registerTool({
5. 一份存档从被放弃的分支上回来了:snake.ts
源码:恢复逻辑会扫描整个会话文件;在分支 A 上存档,回退到存档之前,打开 /snake,那份死掉的存档就回来了。
const entries = ctx.sessionManager.getEntries();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "custom" && entry.customType === SNAKE_SAVE_TYPE) {
6. “最后一条消息”指的是文件里的最后一条:bookmark.ts
源码:缺少 getBranch();回退之后,/bookmark 可能给一条位于被放弃分支上、用户根本看不到的助手消息打上标签。
const entries = ctx.sessionManager.getEntries();
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (entry.type === "message" && entry.message.role === "assistant") {
7. 回退到发现之前,Calculator 仍处于激活状态:kimi-deferred-tools.ts
源码:tool_search 激活了 Calculator,但没有任何 session_tree 处理器重新推导激活的工具清单;导航到发现之前的某个点后,Calculator 仍然是激活的。
const active = pi.getActiveTools();
const added = active.includes("Calculator") ? [] : ["Calculator"];
if (added.length > 0) pi.setActiveTools([...active, ...added]);
// Missing: session_tree → derive active tools from selected branch.
8. 切换会话会提交 worktree:auto-commit-on-exit.ts
源码:缺少一条仅限退出的边界;/new、/resume 和 /fork 都会触发 session_shutdown,进而把脏 worktree 暂存并提交。
pi.on("session_shutdown", async (_event, ctx) => {
// …
await pi.exec("git", ["add", "-A"]);
await pi.exec("git", ["commit", "-m", commitMessage]);
});
9. 实时状态与恢复后的状态不一致:tic-tac-toe.ts
恢复逻辑;用户落子:重建时只接受工具结果,但用户落子是自定义条目;在 X 落子之后、O 落子之前崩溃,X 就消失了。
if (entry.type !== "message") continue;
if (msg.role !== "toolResult") continue;
// User moves take a different path:
pi.appendEntry(SAVE_TYPE, getBoardDetails());
附录 B:弹性推测槽(Elastic Speculative Slots)
界面那一章把协议和结论留在了主阅读路径里。本附录收录论文,以及用于检查 transcript 不变量的完整 TLA+ 模型。

“弹性推测槽”(Elastic Speculative Slots)论文:三层契约、安全性定理以及条件性进展结果,与下方的完整规约一一对应。· 点击可查看完整 PDF。
ElasticSlots.tla — 完整规约
---- MODULE ElasticSlots ----
\* =========================================================================
\* Elastic Speculative Slots: a formally verified rendering protocol for
\* streaming concurrent output blocks through a bounded terminal viewport
\* into append-only scrollback.
\*
\* Three decoupled layers, related by invariants (see ELASTIC_SLOTS2.tex):
\* 1. semantic block state (phase/mode/want/final/emitted per block)
\* 2. logical history ledger (`history`: width-independent, exactly-once)
\* 3. physical native rows (`native`: width-rendered, source-tagged)
\* =========================================================================
EXTENDS Naturals, Sequences, FiniteSets, TLC
\* Naturals: arithmetic; Sequences: <<>>/Len/SubSeq/\o; FiniteSets:
\* Cardinality/IsFiniteSet; TLC: model-checking utilities.
CONSTANTS N, H, MaxResizes, MaxLive, RowValues, SnapshotValues,
NoFinal, Placeholder, Blank, OverflowMarker
\* N : number of block identities (blocks are 1..N, in commit order)
\* H : maximum viewport (live transcript) height, in rows
\* MaxResizes : bound on resize events (keeps the state space finite)
\* MaxLive : uncommitted-block count that constitutes "pressure"
\* RowValues : finite row alphabet (what a semantic line of output "is")
\* SnapshotValues: finite universe of block contents (sequences of rows)
\* NoFinal : sentinel "this block has no final snapshot yet"
\* Placeholder : synthetic viewport row shown for an empty slot
\* Blank : synthetic viewport row for unused screen space
\* OverflowMarker: synthetic viewport row summarizing hidden older blocks
ASSUME
∧ N ∈ ℕ \ {0} \* at least one block
∧ H ∈ ℕ \ {0} \* viewport can be nonempty
∧ MaxResizes ∈ ℕ \* zero resizes is allowed
∧ MaxLive ∈ ℕ \ {0} \* pressure threshold >= 1
∧ IsFiniteSet(RowValues) \* finite row alphabet
∧ RowValues ≠ {} \* ... and nonempty
∧ IsFiniteSet(SnapshotValues) \* finite snapshot universe
∧ SnapshotValues ⊆ Seq(RowValues) \* snapshots are row sequences
∧ ⟨⟩ ∈ SnapshotValues \* the empty snapshot exists
∧ (∃ snapshot ∈ SnapshotValues : Len(snapshot) = 1) \* a length-1 snapshot exists
∧ (∃ snapshot ∈ SnapshotValues : Len(snapshot) > 1) \* a longer one exists too
∧ NoFinal ∉ SnapshotValues \* sentinel distinct from real data
∧ Placeholder ∉ RowValues \* synthetic rows are not
∧ Blank ∉ RowValues \* ... confusable with
∧ OverflowMarker ∉ RowValues \* ... semantic rows,
∧ Placeholder ≠ Blank \* and are pairwise
∧ Placeholder ≠ OverflowMarker \* distinct from
∧ Blank ≠ OverflowMarker \* each other.
Blocks ≜ 1‥N \* the block identities
ModelRows ≜ {"row-a", "row-b"} \* tiny concrete row alphabet for TLC
ModelSnapshots ≜ \* a richer snapshot universe (unused by the shipped cfg)
{⟨⟩, \* empty block
⟨"row-a"⟩, \* one-liner
⟨"row-b"⟩, \* one-liner, other row
⟨"row-a", "row-b"⟩, \* two distinct rows
⟨"row-b", "row-a"⟩, \* order matters
⟨"row-a", "row-b", "row-a"⟩} \* length three, with repeat
SmallModelSnapshots ≜ {⟨⟩, ⟨"row-a"⟩, ⟨"row-a", "row-b"⟩} \* the cfg's universe: lengths 0, 1, 2
WidthValues ≜ {"Wide", "Narrow"} \* two-point abstraction of terminal width
ResizeModes ≜ {"Preserve", "Append", "Rebuild"} \* policy chosen at a width-changing resize
ReplayModes ≜ {"None", "Append", "Rebuild"} \* pending replay (None = no replay in flight)
BlockModes ≜ {"Undeclared", "Mutable", "AppendOnly"} \* presentation contract, fixed at Create
Phases ≜ {"Absent", "Queued", "Active", "Finalized", "Committed"} \* block lifecycle, monotone left-to-right
StopReasons ≜ {"Running", "Graceful", "Detach", "WriteFailure"} \* why the host stopped (Running = it hasn't)
NativeSources ≜ {"Append", "Retire", "Replay", "Resize", "FailedWrite", "Exit"} \* provenance tag on every native row
CellRows ≜ RowValues ∪ {Placeholder, Blank, OverflowMarker} \* what a viewport cell may display
Cells ≜ [owner : 0‥N, row : CellRows] \* a viewport cell: owning block (0 = chrome) + row
TaggedRows ≜ [owner : Blocks, row : RowValues] \* a ledger row: semantic, width-independent
NativeRows ≜ [source : NativeSources, owner : 0‥N, row : CellRows, width : WidthValues]
\* a native row: provenance source, owner, rendered row, and the width it was rendered at
SnapshotLengths ≜ {Len(snapshot) : snapshot ∈ SnapshotValues} \* set of occurring snapshot lengths
MaxSnapshotLength ≜ \* L_max: the longest snapshot length
CHOOSE maximum ∈ SnapshotLengths : \* (CHOOSE is fine here: the maximum
∀ length ∈ SnapshotLengths : length ≤ maximum \* of a finite set is unique)
MaxFailureRows ≜ 2 * N * MaxSnapshotLength \* K_max: upper bound on one physical write batch
\* (factor 2 = worst-case Narrow doubling)
BlankCell ≜ [owner ↦ 0, row ↦ Blank] \* the unused-screen-space cell
OverflowCell ≜ [owner ↦ 0, row ↦ OverflowMarker] \* the "N older blocks hidden" summary cell
\* -------------------------------------------------------------------------
\* State variables (one tuple entry per column of Table 1 in the paper).
\* -------------------------------------------------------------------------
VARIABLES c, phase, mode, want, final, emitted, alloc, target,
history, native, width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason
\* c : commit frontier -- blocks 1..c are committed (retired)
\* phase : lifecycle phase per block
\* mode : Mutable / AppendOnly contract per block
\* want : current speculative snapshot per block
\* final : frozen final snapshot per block (NoFinal until finalized)
\* emitted : rows of the head block already streamed into history
\* alloc : painted slot height per block (rows on screen now)
\* target : requested slot height per block (animation target)
\* history : the logical ledger (layer 2)
\* native : the physical scrollback of the current epoch (layer 3)
\* width, height : current terminal geometry
\* resizes : how many resizes happened (bounded by MaxResizes)
\* epoch : display epoch; Rebuild resets native and bumps this
\* replayMode : pending replay policy (None / Append / Rebuild)
\* replayCursor : first committed block to replay (invariantly 1 while replaying)
\* replayEnd : last committed block to replay (= c at replay start)
\* replayPartial : how many stable head rows to replay
\* replayPrepared : replay frame computed and cut fixed (gates the scheduler)
\* replayCut : rows of the replay frame that must scroll into native
\* flush : explicit "retire everything" request (never reset)
\* shutdown : graceful shutdown initiated
\* running : host still alive; every action requires it
\* stopReason : why we stopped (Running while alive)
vars ≜ ⟨c, phase, mode, want, final, emitted, alloc, target,
history, native, width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩
\* the full variable tuple, used for stuttering ([Next]_vars) and UNCHANGED
Maximum(left, right) ≜ IF left ≥ right THEN left ELSE right \* max of two naturals
\* -------------------------------------------------------------------------
\* Width rendering: the two-point abstraction of soft-wrap reflow.
\* -------------------------------------------------------------------------
RECURSIVE DoubleRows(_)
DoubleRows(snapshot) ≜ \* Narrow rendering:
IF Len(snapshot) = 0 THEN ⟨⟩ \* empty stays empty;
ELSE ⟨Head(snapshot), Head(snapshot)⟩ ∘ DoubleRows(Tail(snapshot))
\* every semantic row occupies TWO physical rows (models a wrapped line)
Render(snapshot, wx) ≜ IF wx = "Wide" THEN snapshot ELSE DoubleRows(snapshot)
\* rho_omega: Wide = identity, Narrow = row doubling; prefix-monotone by construction
Tag(i, snapshot) ≜ \* tg_i: stamp each row with its owner
[j ∈ 1‥Len(snapshot) ↦ [owner ↦ i, row ↦ snapshot[j]]]
SnapshotSlice(snapshot, lo, hi) ≜ \* s[lo..hi], empty when lo > hi
IF lo > hi THEN ⟨⟩ ELSE SubSeq(snapshot, lo, hi)
TagSlice(i, snapshot, lo, hi) ≜ Tag(i, SnapshotSlice(snapshot, lo, hi)) \* owner-tagged slice
NativeTag(source, i, snapshot, wx) ≜ \* ntg: render at width wx, then tag
[j ∈ 1‥Len(Render(snapshot, wx)) ↦ \* one native row per RENDERED row
[source ↦ source, owner ↦ i, \* provenance + owner
row ↦ Render(snapshot, wx)[j], width ↦ wx]] \* rendered row + width it used
NativeTagSlice(source, i, snapshot, lo, hi, wx) ≜ \* native-tag a semantic slice
NativeTag(source, i, SnapshotSlice(snapshot, lo, hi), wx)
NativeCells(source, cells, wx) ≜ \* lift screen cells to native rows
[j ∈ 1‥Len(cells) ↦ \* (used when the emulator itself
[source ↦ source, owner ↦ cells[j].owner, \* pushes viewport rows into
row ↦ cells[j].row, width ↦ wx]] \* scrollback, e.g. on resize/exit)
PrefixOf(sequence, count) ≜ [j ∈ 1‥count ↦ sequence[j]] \* first `count` elements
\* -------------------------------------------------------------------------
\* The logical ledger as a FUNCTION of state (invariant ECH says
\* `history` always equals CommittedRows(c, final) \o PartialHeadRows).
\* -------------------------------------------------------------------------
RECURSIVE CommittedRows(_, _)
CommittedRows(k, finals) ≜ \* C(k): finals of blocks 1..k,
IF k = 0 THEN ⟨⟩ \* tagged, concatenated in
ELSE CommittedRows(k - 1, finals) ∘ Tag(k, finals[k]) \* block (= commit) order
RECURSIVE TaggedRange(_, _, _)
TaggedRange(lo, hi, finals) ≜ \* tagged finals of blocks lo..hi
IF lo > hi THEN ⟨⟩ \* (empty range allowed)
ELSE Tag(lo, finals[lo]) ∘ TaggedRange(lo + 1, hi, finals)
RECURSIVE NativeRange(_, _, _, _, _)
NativeRange(source, lo, hi, finals, wx) ≜ \* same, but width-rendered and
IF lo > hi THEN ⟨⟩ \* source-tagged for `native`
ELSE NativeTag(source, lo, finals[lo], wx)
∘ NativeRange(source, lo + 1, hi, finals, wx)
RetirementRows(lo, hi, finals, firstEmitted) ≜ \* logical retirement batch:
IF lo > hi THEN ⟨⟩ \* head block lo contributes only
ELSE TagSlice(lo, finals[lo], firstEmitted + 1, Len(finals[lo])) \* its UNstreamed suffix,
∘ TaggedRange(lo + 1, hi, finals) \* later blocks contribute in full
NativeRetirementRows(source, lo, hi, finals, firstEmitted, wx) ≜
IF lo > hi THEN ⟨⟩ \* physical twin of RetirementRows:
ELSE NativeTagSlice( \* the same rows,
source, \* provenance-tagged
lo, \* (Retire on success,
finals[lo], \* FailedWrite on failure),
firstEmitted + 1, \* starting after the already-
Len(finals[lo]), \* streamed head prefix,
wx \* rendered at the current width
)
∘ NativeRange(source, lo + 1, hi, finals, wx) \* then full later finals
FinalizedRange(lo, hi) ≜ \* "blocks lo..hi are all Finalized"
∀ i ∈ lo‥hi : phase[i] = "Finalized" \* (a retirement batch precondition)
Unemitted(snapshot, i, emission) ≜ \* U_i(s): the part of s not yet
IF mode[i] = "AppendOnly" \* streamed into history --
THEN SnapshotSlice(snapshot, emission[i] + 1, Len(snapshot)) \* suffix for append-only,
ELSE snapshot \* everything for mutable blocks
\* -------------------------------------------------------------------------
\* Live-viewport geometry: who is presented, who is visible, how much
\* space is reserved. All operators take the ambient tuple explicitly so
\* that action guards can evaluate them at SUCCESSOR values.
\* -------------------------------------------------------------------------
Presented(ph, finals, emission, i, wx) ≜ \* block i occupies viewport iff
∨ ph[i] = "Active" \* it is actively producing, or
∨ ∧ ph[i] = "Finalized" \* it is finalized AND still has
∧ Len(Render(Unemitted(finals[i], i, emission), wx)) > 0 \* unstreamed content to show
PresentedSet(ph, finals, emission, wx) ≜ \* the set of presented blocks
{i ∈ Blocks : Presented(ph, finals, emission, i, wx)}
PresentedCount(ph, finals, emission, wx) ≜ \* pi: how many are presented
Cardinality(PresentedSet(ph, finals, emission, wx))
Overflow(ph, finals, emission, wx, hx) ≜ \* ovf: more presented blocks
PresentedCount(ph, finals, emission, wx) > hx \* than viewport rows
SummaryRows(ph, finals, emission, wx, hx) ≜ \* sigma: one summary row is
IF hx > 0 ∧ Overflow(ph, finals, emission, wx, hx) THEN 1 ELSE 0 \* shown iff overflowing (and h>0)
NewerPresented(ph, finals, emission, wx, i) ≜ \* how many presented blocks are
Cardinality({ \* NEWER (higher index) than i --
j ∈ Blocks : \* used to privilege recency
j > i ∧ Presented(ph, finals, emission, j, wx)
})
VisiblePresented(ph, finals, emission, wx, hx, i) ≜ \* vis(i): presented AND, under
∧ Presented(ph, finals, emission, i, wx) \* overflow, among the hx-1
∧ IF Overflow(ph, finals, emission, wx, hx) \* newest presented blocks
THEN ∧ hx > 0 \* (one row is sacrificed to
∧ NewerPresented(ph, finals, emission, wx, i) < hx - 1 \* the summary marker)
ELSE TRUE \* no overflow: presented = visible
RECURSIVE AllocationTotal(_, _)
AllocationTotal(al, i) ≜ \* sum of painted heights,
IF i > N THEN 0 ELSE al[i] + AllocationTotal(al, i + 1) \* blocks i..N
RECURSIVE ReservationTotal(_, _, _)
ReservationTotal(al, requested, i) ≜ \* Res: each block is charged
IF i > N THEN 0 \* max(painted, requested) --
ELSE Maximum(al[i], requested[i]) + ReservationTotal(al, requested, i + 1)
\* growth pays up front, shrink keeps its old charge until painted
AllocationStateOK(al, requested, ph, finals, emission, wx, hx) ≜ \* A_OK: allocation admissibility
∧ al ∈ [Blocks → 0‥H] \* painted heights in range
∧ requested ∈ [Blocks → 0‥H] \* requested heights in range
∧ ∀ i ∈ Blocks :
IF VisiblePresented(ph, finals, emission, wx, hx, i)
THEN IF ph[i] = "Active"
THEN ∧ al[i] ∈ 1‥H \* visible active: painted >= 1,
∧ requested[i] ∈ 1‥H \* target >= 1 (may differ: animating)
ELSE ∧ al[i] ∈ 1‥H \* visible finalized: painted >= 1,
∧ requested[i] = al[i] \* and frozen (no more animation)
ELSE ∧ al[i] = 0 \* invisible blocks hold
∧ requested[i] = 0 \* no space at all
∧ ReservationTotal(al, requested, 1) \* reservation invariant:
+ SummaryRows(ph, finals, emission, wx, hx) ≤ hx \* reservations + summary fit in h
CanonicalAllocation(ph, finals, emission, wx, hx) ≜ \* kappa: the safe default --
[i ∈ Blocks ↦ \* one row per visible block,
IF VisiblePresented(ph, finals, emission, wx, hx, i) THEN 1 ELSE 0] \* zero otherwise
SnapshotHeight(ph, wants, finals, i, wx) ≜ \* dm(i): row demand of block i
CASE ph[i] = "Active" →
Maximum(1, Len(Render(Unemitted(wants[i], i, emitted), wx))) \* live: >= 1 row
□ ph[i] = "Queued" →
Maximum(1, Len(Render(Unemitted(wants[i], i, emitted), wx))) \* queued demands space too
□ ph[i] = "Finalized" →
Len(Render(Unemitted(finals[i], i, emitted), wx)) \* finalized: exactly its unstreamed rows
□ OTHER → 0 \* absent/committed demand nothing
RECURSIVE FullRows(_, _, _, _, _)
FullRows(ph, wants, finals, wx, i) ≜ \* D: total row demand of
IF i > N THEN 0 \* blocks i..N
ELSE SnapshotHeight(ph, wants, finals, i, wx)
+ FullRows(ph, wants, finals, wx, i + 1)
CreatedCount ≜ Cardinality({i ∈ Blocks : phase[i] ≠ "Absent"}) \* gamma: how many blocks exist
PartialHeadExists ≜ \* PH: the head block (c+1) has
∧ c < CreatedCount \* been created,
∧ mode[c + 1] = "AppendOnly" \* is append-only,
∧ phase[c + 1] ∈ {"Active", "Finalized"} \* is live,
∧ emitted[c + 1] > 0 \* and has streamed some rows
PartialHeadRows ≜ \* A(c): the head's streamed
IF PartialHeadExists \* prefix as tagged ledger rows
THEN TagSlice(c + 1, want[c + 1], 1, emitted[c + 1]) \* (prefix of `want`, stable by
ELSE ⟨⟩ \* the append-only contract)
RowPressure ≜ FullRows(phase, want, final, width, 1) > height \* demand exceeds viewport
Pressure ≜ \* pressure = row pressure OR
∨ RowPressure \* too many uncommitted
∨ CreatedCount - c ≥ MaxLive \* blocks piling up
RetirementRequested ≜ flush ∨ Pressure \* Req: when retirement may fire
Replaying ≜ replayMode ≠ "None" \* a replay is in flight
PreviewSource(i) ≜ \* what a slot displays:
IF phase[i] = "Active" \* live blocks show their
THEN Unemitted(want[i], i, emitted) \* unstreamed speculation,
ELSE Unemitted(final[i], i, emitted) \* others their unstreamed final
PreviewCell(i, snapshot) ≜ \* the representative cell of a slot:
LET rendered ≜ Render(snapshot, width) IN \* render at current width;
[owner ↦ i,
row ↦ IF Len(rendered) = 0 \* empty content shows the
THEN Placeholder \* placeholder row, otherwise
ELSE rendered[Len(rendered)]] \* the LAST rendered row (tail view)
Repeat(value, count) ≜ [j ∈ 1‥count ↦ value] \* value^count as a sequence
Slot(i, snapshot, allocation) ≜ Repeat(PreviewCell(i, snapshot), allocation)
\* a slot = its preview cell repeated alloc[i] times (abstracting the real tail window)
RECURSIVE PresentedCells(_)
PresentedCells(i) ≜ \* all slots, ascending block
IF i > N THEN ⟨⟩ \* order (newest at the bottom,
ELSE (IF alloc[i] = 0 THEN ⟨⟩ ELSE Slot(i, PreviewSource(i), alloc[i])) \* next to the cursor);
∘ PresentedCells(i + 1) \* zero-alloc blocks contribute nothing
Screen ≜ \* Q: the whole viewport, top to bottom:
Repeat(
BlankCell, \* blank filler first,
height - AllocationTotal(alloc, 1) - SummaryRows(phase, final, emitted, width, height)
) \* (exactly the unclaimed rows)
∘ (IF SummaryRows(phase, final, emitted, width, height) = 1
THEN ⟨OverflowCell⟩ \* then the overflow summary if any,
ELSE ⟨⟩)
∘ PresentedCells(1) \* then the block slots
\* -------------------------------------------------------------------------
\* Replay geometry: what a width-changing resize must re-render.
\* -------------------------------------------------------------------------
ReplayRows ≜ \* R: the full replay frame --
IF ¬Replaying
THEN ⟨⟩ \* nothing when no replay pending
ELSE NativeRange("Replay", replayCursor, replayEnd, final, width) \* committed finals 1..c
∘ (IF replayPartial = 0 \* re-rendered at the NEW width,
THEN ⟨⟩ \* plus the head's already-
ELSE NativeTagSlice( \* streamed stable prefix
"Replay", \* (if it had streamed rows
replayEnd + 1, \* at resize time) --
want[replayEnd + 1], \* prefix of want, immutable
1, \* under the append-only
replayPartial, \* contract, so stable while
width \* the replay is in flight
))
ReplayRoom ≜ \* how many blank rows the
Cardinality({j ∈ 1‥height : Screen[j] = BlankCell}) \* viewport can absorb scroll-free
RequiredReplayCut ≜ \* cut*: replay rows that do NOT
IF Len(ReplayRows) > ReplayRoom THEN Len(ReplayRows) - ReplayRoom ELSE 0
\* fit in the blank region and must scroll into native scrollback
PreparedReplayTail ≜ \* the part painted bottom-first
IF replayPrepared \* into blank rows (no scroll);
THEN SnapshotSlice(ReplayRows, replayCut + 1, Len(ReplayRows)) \* only meaningful once
ELSE ⟨⟩ \* the frame is prepared
Prefix(left, right) ≜ \* left is a prefix of right
∧ Len(left) ≤ Len(right) \* (the partial order behind the
∧ ∀ j ∈ 1‥Len(left) : left[j] = right[j] \* append-only contract)
NoEarlierQueued(i) ≜ ∀ j ∈ 1‥(i - 1) : phase[j] ≠ "Queued" \* FIFO admission guard
\* =========================================================================
\* Initial state: nothing created, full-height wide viewport, empty
\* histories, no replay, host running.
\* =========================================================================
Init ≜
∧ c = 0 \* nothing committed
∧ phase = [i ∈ Blocks ↦ "Absent"] \* no block exists
∧ mode = [i ∈ Blocks ↦ "Undeclared"] \* no contract chosen
∧ want = [i ∈ Blocks ↦ ⟨⟩] \* empty speculation
∧ final = [i ∈ Blocks ↦ NoFinal] \* nothing finalized
∧ emitted = [i ∈ Blocks ↦ 0] \* nothing streamed
∧ alloc = [i ∈ Blocks ↦ 0] \* no slot painted
∧ target = [i ∈ Blocks ↦ 0] \* no slot requested
∧ history = ⟨⟩ \* empty ledger (= CommittedRows(0,...))
∧ native = ⟨⟩ \* empty scrollback
∧ width = "Wide" \* initial geometry:
∧ height = H \* wide, full height
∧ resizes = 0 \* no resizes yet
∧ epoch = 0 \* first display epoch
∧ replayMode = "None" \* no replay pending
∧ replayCursor = 0 \* replay window empty
∧ replayEnd = 0
∧ replayPartial = 0
∧ replayPrepared = FALSE \* no frame prepared
∧ replayCut = 0
∧ flush = FALSE \* no flush requested
∧ shutdown = FALSE \* not shutting down
∧ running = TRUE \* host alive
∧ stopReason = "Running" \* ... and not stopped
\* =========================================================================
\* Actions. Every guard conjoins `running`; most also require ~shutdown.
\* =========================================================================
Create(declaration) ≜ \* a new block is declared
∧ running \* host alive
∧ ¬shutdown \* no new work during shutdown
∧ CreatedCount < N \* an identity is still free
∧ phase[CreatedCount + 1] = "Absent" \* blocks are created contiguously
∧ declaration ∈ {"Mutable", "AppendOnly"} \* contract chosen now, forever
∧ phase' = [phase EXCEPT ![CreatedCount + 1] = "Queued"] \* enters the queue
∧ mode' = [mode EXCEPT ![CreatedCount + 1] = declaration] \* contract recorded
∧ UNCHANGED ⟨c, want, final, emitted, alloc, target, history, native,
width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* pure bookkeeping: no paint, no history
Admit(i) ≜ \* a queued block gets a live slot
∧ running \* host alive
∧ ¬shutdown \* not during shutdown
∧ phase[i] = "Queued" \* must be waiting
∧ NoEarlierQueued(i) \* FIFO: no older block still queued
∧ LET newPhase ≜ [phase EXCEPT ![i] = "Active"] \* candidate successor phase,
newAlloc ≜ [alloc EXCEPT ![i] = 1] \* with a fresh 1-row slot
newTarget ≜ [target EXCEPT ![i] = 1] \* painted and requested
IN ∧ ¬Overflow(newPhase, final, emitted, width, height) \* admission may NOT overflow --
∧ AllocationStateOK(newAlloc, newTarget, newPhase, final, emitted, width, height)
\* ... and the new slot must fit the reservation invariant; otherwise the
\* block simply stays queued (denied, not summarized)
∧ phase' = newPhase \* commit the candidate state
∧ alloc' = newAlloc
∧ target' = newTarget
∧ UNCHANGED ⟨c, mode, want, final, emitted, history, native, width, height,
resizes, epoch, replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* repaint only: histories untouched
Update(i, snapshot) ≜ \* speculation evolves
∧ running \* host alive
∧ ¬shutdown \* not during shutdown
∧ phase[i] ∈ {"Queued", "Active"} \* only unfinalized blocks change
∧ (mode[i] = "Mutable" ∨ Prefix(want[i], snapshot)) \* THE append-only contract:
\* mutable blocks may replace their content arbitrarily; append-only
\* blocks may only extend it (old rows are immutable)
∧ snapshot ≠ want[i] \* no stuttering updates
∧ want' = [want EXCEPT ![i] = snapshot] \* the only writer of speculation
∧ UNCHANGED ⟨c, phase, mode, final, emitted, alloc, target, history, native,
width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* repaint only
RequestAllocation(newTarget) ≜ \* the app asks for new slot heights
∧ running \* host alive
∧ ¬shutdown \* not during shutdown
∧ AllocationStateOK(alloc, newTarget, phase, final, emitted, width, height)
\* admissible against the CURRENT paint: max(painted, newly-requested)
\* must fit, so every later animation frame is pre-paid (dominance)
∧ newTarget ≠ target \* no stuttering requests
∧ target' = newTarget \* targets change; paint doesn't yet
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, alloc, history, native,
width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* nothing visible happens yet
BridgeHeight(sampled, requested) ≜ \* B(a,t): next painted height
IF sampled < requested THEN requested \* growth jumps straight to target;
ELSE IF sampled > 2 ∧ requested = 1 THEN 2 \* a deep shrink (>2 -> 1) pauses at 2
ELSE requested \* all other shrinks are direct
\* the 2-row bridge frame makes deep collapses read as contractions, not snaps
ApplyAllocation(i) ≜ \* one animation frame is painted
∧ running \* host alive
∧ ¬shutdown \* not during shutdown
∧ phase[i] = "Active" \* only active slots animate
∧ alloc[i] ≠ target[i] \* something to do
∧ LET nextHeight ≜ BridgeHeight(alloc[i], target[i]) \* bridged next height
newAlloc ≜ [alloc EXCEPT ![i] = nextHeight]
IN ∧ AllocationStateOK(newAlloc, target, phase, final, emitted, width, height)
\* always satisfiable along a bridge: B never raises max(alloc, target)
∧ alloc' = newAlloc \* paint the frame
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, target, history, native,
width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* repaint only
FinalizeActive(i, snapshot) ≜ \* a live block completes
∧ running \* host alive
∧ ¬shutdown \* not during shutdown
∧ phase[i] = "Active" \* it was producing
∧ (mode[i] = "Mutable" ∨ Prefix(want[i], snapshot)) \* final must honor the contract
∧ LET newPhase ≜ [phase EXCEPT ![i] = "Finalized"]
newFinal ≜ [final EXCEPT ![i] = snapshot] \* the final value, frozen forever
newAlloc ≜ CanonicalAllocation(newPhase, newFinal, emitted, width, height)
IN ∧ phase' = newPhase \* lifecycle advances
∧ want' = [want EXCEPT ![i] = snapshot] \* want converges to final
∧ final' = newFinal \* (invariant: final = want)
∧ alloc' = newAlloc \* ALL slots collapse to canonical
∧ target' = newAlloc \* 1-row previews: finished content
∧ UNCHANGED ⟨c, mode, emitted, history, native, width, height, \* no longer animates
resizes, epoch, replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* repaint only: nothing retires yet
FinalizeQueued(i, snapshot) ≜ \* a block completes WITHOUT ever
∧ running \* having held a slot (finished
∧ ¬shutdown \* before space freed up)
∧ phase[i] = "Queued" \* straight from the queue
∧ (mode[i] = "Mutable" ∨ Prefix(want[i], snapshot)) \* same contract check
∧ LET newPhase ≜ [phase EXCEPT ![i] = "Finalized"]
newWant ≜ [want EXCEPT ![i] = snapshot]
newFinal ≜ [final EXCEPT ![i] = snapshot]
newAlloc ≜ CanonicalAllocation(newPhase, newFinal, emitted, width, height)
IN ∧ phase' = newPhase \* note: THIS transition may cause
∧ want' = newWant \* overflow (a hidden block becomes
∧ final' = newFinal \* presented) -- summarization, not
∧ alloc' = newAlloc \* denial, handles it here
∧ target' = newAlloc
∧ UNCHANGED ⟨c, mode, emitted, history, native, width, height,
resizes, epoch, replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* repaint only
AppendStable ≜ \* natural streaming: ONE stable row
∧ running \* of the append-only HEAD block
∧ ¬shutdown \* scrolls into both histories
∧ ¬Replaying \* never interleaves with replay
∧ c < CreatedCount \* a head block exists
∧ mode[c + 1] = "AppendOnly" \* only append-only blocks stream
∧ phase[c + 1] ∈ {"Active", "Finalized"} \* and only while live
∧ RowPressure \* only under ROW pressure: with
\* room to spare, stable rows stay in the viewport (still repositionable)
∧ emitted[c + 1] < Len(want[c + 1]) \* a stable row remains to stream
∧ LET next ≜ emitted[c + 1] + 1 \* index of the row to emit
newEmitted ≜ [emitted EXCEPT ![c + 1] = next]
newAlloc ≜ CanonicalAllocation(phase, final, newEmitted, width, height)
IN ∧ history' = history ∘ TagSlice(c + 1, want[c + 1], next, next) \* ledger += 1 semantic row
∧ native' =
native
∘ NativeTagSlice("Append", c + 1, want[c + 1], next, next, width)
\* native += the same row, rendered (1 or 2 physical rows), tagged Append
∧ emitted' = newEmitted \* the stable frontier advances
∧ alloc' = newAlloc \* layout recanonicalizes (the
∧ target' = newAlloc \* streamed row left the viewport)
∧ UNCHANGED ⟨c, phase, mode, want, final,
width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* frontier c itself does not move
CompleteAppendOnly ≜ \* the fully-streamed head commits
∧ running \* host alive
\* (deliberately NO ~shutdown: draining the head stays possible while
\* shutting down)
∧ ¬Replaying \* never during replay
∧ c < CreatedCount \* head exists
∧ mode[c + 1] = "AppendOnly" \* head is append-only
∧ phase[c + 1] = "Finalized" \* head is done
∧ emitted[c + 1] = Len(final[c + 1]) \* every row already streamed
∧ LET newPhase ≜ [phase EXCEPT ![c + 1] = "Committed"]
newEmitted ≜ [emitted EXCEPT ![c + 1] = 0] \* emitted counter retires with it
newAlloc ≜ CanonicalAllocation(newPhase, final, newEmitted, width, height)
IN ∧ c' = c + 1 \* frontier advances: PURE
∧ phase' = newPhase \* bookkeeping -- every row is
∧ emitted' = newEmitted \* already in both histories,
∧ alloc' = newAlloc \* so nothing is written
∧ target' = newAlloc
∧ UNCHANGED ⟨mode, want, final, history, native, width, height,
resizes, epoch, replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* note: history unchanged!
BeginFlush ≜ \* someone asks for full retirement
∧ running \* host alive
∧ ¬flush \* idempotent: set once,
∧ flush' = TRUE \* never reset
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, alloc, target,
history, native, width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
shutdown, running, stopReason⟩ \* a pure request: no effect yet
RetireSuccess(batchEnd) ≜ \* in-order retirement of a batch
∧ running \* host alive
∧ ¬Replaying \* never during replay
∧ batchEnd ∈ (c + 1)‥N \* batch = blocks c+1 .. batchEnd
∧ FinalizedRange(c + 1, batchEnd) \* ... ALL of them finalized
∧ RetirementRequested \* only under flush or pressure
∧ history' =
history ∘ RetirementRows(c + 1, batchEnd, final, emitted[c + 1])
\* ledger += head's unstreamed suffix, then later finals in full
\* (emitted[c+1] is the only possibly-nonzero emitted counter)
∧ native' =
native
∘ NativeRetirementRows( \* native += the same rows,
"Retire", \* tagged Retire, rendered at
c + 1, \* the current width; realized
batchEnd, \* on a real terminal as ONE
final, \* streamed write (paper,
emitted[c + 1], \* Lemma "streaming
width \* realization")
)
∧ LET newPhase ≜ [i ∈ Blocks ↦
IF i ≤ batchEnd THEN "Committed" ELSE phase[i]] \* batch commits
newEmitted ≜ [i ∈ Blocks ↦
IF i ≤ batchEnd THEN 0 ELSE emitted[i]] \* counters reset
newAlloc ≜ CanonicalAllocation(newPhase, final, newEmitted, width, height)
IN ∧ c' = batchEnd \* frontier jumps to batch end
∧ phase' = newPhase
∧ emitted' = newEmitted
∧ alloc' = newAlloc \* retired slots disappear;
∧ target' = newAlloc \* survivors recanonicalize
∧ UNCHANGED ⟨mode, want, final, width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown, running, stopReason⟩ \* finals themselves are untouched
RetireFailure(batchEnd, count) ≜ \* the SAME write, torn partway:
∧ running \* same enabling conditions
∧ ¬Replaying \* as RetireSuccess ...
∧ batchEnd ∈ (c + 1)‥N
∧ FinalizedRange(c + 1, batchEnd)
∧ RetirementRequested
∧ LET rows ≜
NativeRetirementRows( \* the batch that WOULD have
"FailedWrite", \* been written, tagged
c + 1, \* FailedWrite for forensics
batchEnd,
final,
emitted[c + 1],
width
)
IN ∧ count ∈ 0‥Len(rows) \* the terminal accepted `count`
∧ native' = native ∘ PrefixOf(rows, count) \* rows: an arbitrary PREFIX --
\* never reordered, never a row from outside the batch
∧ running' = FALSE \* fail-stop: the host halts;
∧ stopReason' = "WriteFailure" \* no retry path exists, so
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, alloc, target, history,
width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial, replayPrepared, replayCut, flush, shutdown⟩
\* CRITICAL: c and history do NOT advance -- the ledger never lies about
\* what committed, so duplication/reordering after failure is impossible
Resize(newWidth, newHeight, resizePolicy, pushed) ≜ \* terminal geometry changes
∧ running \* host alive
∧ ¬shutdown \* not during shutdown
∧ resizes < MaxResizes \* bounded (finite model)
∧ newWidth ∈ WidthValues \* new geometry and the
∧ newHeight ∈ 0‥H \* policy for native history
∧ resizePolicy ∈ ResizeModes
∧ newWidth ≠ width ∨ newHeight ≠ height \* an actual change
∧ pushed ∈ 0‥Len(Screen) \* emulator may scroll 0..h top
\* viewport rows into scrollback during the resize (e.g. height shrink)
∧ LET widthChanged ≜ newWidth ≠ width
effectiveMode ≜ IF widthChanged THEN resizePolicy ELSE "Preserve"
\* height-only resizes never replay: rendered rows are still valid
pushedRows ≜ NativeCells("Resize", PrefixOf(Screen, pushed), width)
\* rows pushed by the emulator, tagged Resize, at the OLD width
beginReplay ≜ effectiveMode ≠ "Preserve" ∧ (c > 0 ∨ PartialHeadExists)
\* replay only if there is committed/streamed content to re-render
newPhase ≜ phase \* lifecycle is untouched
newAlloc ≜ CanonicalAllocation(newPhase, final, emitted, newWidth, newHeight)
IN ∧ width' = newWidth \* adopt the new geometry
∧ height' = newHeight
∧ resizes' = resizes + 1 \* burn one resize budget
∧ alloc' = newAlloc \* layout recanonicalizes at
∧ target' = newAlloc \* the new geometry
∧ native' = IF effectiveMode = "Rebuild"
THEN ⟨⟩ \* Rebuild: native display is wiped ...
ELSE native ∘ pushedRows \* else: record what the emulator pushed
∧ epoch' = IF effectiveMode = "Rebuild" THEN epoch + 1 ELSE epoch
\* ... and the display epoch increments (native monotonicity is epoch-scoped)
∧ replayMode' =
IF beginReplay THEN effectiveMode \* start a replay,
ELSE IF Replaying THEN replayMode ELSE "None" \* or keep/clear the old one
∧ replayCursor' =
IF beginReplay THEN 1 \* replay window = committed
ELSE IF Replaying THEN replayCursor ELSE 0 \* blocks 1..c
∧ replayEnd' =
IF beginReplay THEN c
ELSE IF Replaying THEN replayEnd ELSE 0
∧ replayPartial' =
IF beginReplay
THEN IF PartialHeadExists THEN emitted[c + 1] ELSE 0 \* plus the streamed head prefix
ELSE IF Replaying THEN replayPartial ELSE 0
∧ replayPrepared' = FALSE \* ANY resize invalidates a
∧ replayCut' = 0 \* previously prepared frame
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, history,
flush, shutdown, running, stopReason⟩
\* resize logical-neutrality: ledger, frontier, and semantics never move
PrepareReplay ≜ \* compute the replay frame
∧ running \* host alive
∧ Replaying \* a replay is pending
∧ ¬replayPrepared \* and not yet prepared
∧ replayPrepared' = TRUE \* freeze the frame NOW:
∧ replayCut' = RequiredReplayCut \* cut = rows that must scroll
\* from here the scheduler gate (see Next) admits ONLY the two replay
\* writes, so the sampled cut cannot be invalidated by interleaving
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, alloc, target,
history, native, width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
flush, shutdown, running, stopReason⟩ \* pure computation: no write yet
ReplaySynchronousSuccess ≜ \* the single buffered write lands
∧ running \* host alive
∧ Replaying \* replay pending
∧ replayPrepared \* frame prepared (gate open)
∧ native' = native ∘ PrefixOf(ReplayRows, replayCut) \* exactly `cut` rows scroll into
\* native; the tail was painted into blank rows (no scroll, no history)
∧ replayMode' = "None" \* replay fully drains:
∧ replayCursor' = 0 \* all replay state returns
∧ replayEnd' = 0 \* to its idle shape
∧ replayPartial' = 0
∧ replayPrepared' = FALSE
∧ replayCut' = 0
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, alloc, target,
history, width, height, resizes, epoch,
flush, shutdown, running, stopReason⟩ \* logically neutral: ledger untouched
ReplaySynchronousFailure(count) ≜ \* the same write, torn partway
∧ running \* host alive
∧ Replaying \* replay pending
∧ replayPrepared \* frame prepared
∧ count ∈ 0‥replayCut \* an arbitrary prefix of the
∧ native' = native ∘ PrefixOf(ReplayRows, count) \* scrolled portion landed
∧ running' = FALSE \* fail-stop, as with
∧ stopReason' = "WriteFailure" \* RetireFailure: halt, no retry
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, alloc, target, history,
width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
flush, shutdown⟩ \* ledger and frontier still truthful
BeginGracefulShutdown ≜ \* wind-down begins
∧ running \* host alive
∧ ¬shutdown \* only once
∧ LET newPhase ≜ [i ∈ Blocks ↦
IF phase[i] = "Absent" THEN "Absent" \* never-created stay absent;
ELSE IF i ≤ c THEN "Committed" ELSE "Finalized"] \* all live work freezes
newFinal ≜ [i ∈ Blocks ↦
IF phase[i] = "Absent" THEN NoFinal \* absent: still no final;
ELSE IF i ≤ c ∨ phase[i] = "Finalized"
THEN final[i] \* already-frozen finals kept;
ELSE want[i]] \* queued/active freeze AT their
newAlloc ≜ CanonicalAllocation(newPhase, newFinal, emitted, width, height)
IN ∧ phase' = newPhase \* current speculation (f := w)
∧ final' = newFinal
∧ alloc' = newAlloc \* layout collapses to canonical
∧ target' = newAlloc
∧ flush' = TRUE \* permanent flush: everything
∧ shutdown' = TRUE \* must drain, then exit
∧ UNCHANGED ⟨c, mode, want, emitted, history, native, width, height,
resizes, epoch, replayMode, replayCursor, replayEnd, replayPartial,
replayPrepared, replayCut,
running, stopReason⟩ \* nothing retires in this step itself
GracefulExit(push) ≜ \* clean exit after full drain
∧ running \* host alive
∧ shutdown \* shutdown was initiated,
∧ ¬Replaying \* replay has drained,
∧ c = CreatedCount \* and EVERY block committed
∧ push ∈ 0‥1 \* optionally scroll one last row
∧ push = 0 ∨ height > 0 \* (only if a viewport row exists)
∧ running' = FALSE \* host stops
∧ stopReason' = "Graceful" \* ... cleanly
∧ native' = IF push = 0
THEN native \* either no final scroll, or the
ELSE native ∘ NativeCells("Exit", ⟨Screen[1]⟩, width)
\* top viewport row scrolls out (restoring the shell prompt),
\* tagged Exit
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, alloc, target, history,
width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial, replayPrepared, replayCut, flush, shutdown⟩
DetachExit(push) ≜ \* abandon ship: exit NOW,
∧ running \* uncommitted work is dropped
∧ ¬shutdown \* (a detach, not a shutdown)
∧ push ∈ 0‥1 \* same optional final scroll
∧ push = 0 ∨ height > 0
∧ running' = FALSE \* host stops
∧ stopReason' = "Detach"
∧ native' = IF push = 0
THEN native
ELSE native ∘ NativeCells("Exit", ⟨Screen[1]⟩, width)
∧ UNCHANGED ⟨c, phase, mode, want, final, emitted, alloc, target, history,
width, height, resizes, epoch,
replayMode, replayCursor, replayEnd, replayPartial, replayPrepared, replayCut, flush, shutdown⟩
\* ECH guarantees `history` holds exactly the committed content at detach
\* -------------------------------------------------------------------------
\* Existentially closed action wrappers (for fairness and Next).
\* -------------------------------------------------------------------------
RetireSuccessAction ≜ ∃ batchEnd ∈ Blocks : RetireSuccess(batchEnd) \* some batch retires
RetireFailureAction ≜ \* some batch write fails at
∃ batchEnd ∈ Blocks : \* some prefix length
∃ count ∈ 0‥MaxFailureRows : RetireFailure(batchEnd, count)
ReplaySynchronousFailureAction ≜ \* replay write fails at some
∃ count ∈ 0‥MaxFailureRows : ReplaySynchronousFailure(count) \* prefix length
\* -------------------------------------------------------------------------
\* The scheduler gate: once a replay frame is prepared, the ONLY possible
\* steps are the replay write landing or failing. This is what the word
\* "synchronous" means, and it is what keeps replayCut = RequiredReplayCut
\* stable (nothing may repaint in between).
\* -------------------------------------------------------------------------
Next ≜
IF replayPrepared
THEN ReplaySynchronousSuccess ∨ ReplaySynchronousFailureAction \* gate closed: write or die
ELSE ∨ ∃ declaration ∈ {"Mutable", "AppendOnly"} : Create(declaration) \* gate open:
∨ ∃ i ∈ Blocks : Admit(i) \* any protocol
∨ ∃ i ∈ Blocks, snapshot ∈ SnapshotValues : Update(i, snapshot) \* step may fire
∨ ∃ newTarget ∈ [Blocks → 0‥H] : RequestAllocation(newTarget)
∨ ∃ i ∈ Blocks : ApplyAllocation(i)
∨ ∃ i ∈ Blocks, snapshot ∈ SnapshotValues : FinalizeActive(i, snapshot)
∨ ∃ i ∈ Blocks, snapshot ∈ SnapshotValues : FinalizeQueued(i, snapshot)
∨ AppendStable
∨ CompleteAppendOnly
∨ BeginFlush
∨ RetireSuccessAction
∨ RetireFailureAction
∨ ∃ newWidth ∈ WidthValues, newHeight ∈ 0‥H,
resizePolicy ∈ ResizeModes, pushed ∈ 0‥H :
Resize(newWidth, newHeight, resizePolicy, pushed)
∨ PrepareReplay
∨ BeginGracefulShutdown
∨ ∃ push ∈ 0‥1 : GracefulExit(push)
∨ ∃ push ∈ 0‥1 : DetachExit(push)
Spec ≜
∧ Init \* start in the initial state,
∧ □[Next]_vars \* take Next steps (or stutter),
∧ WF_vars(RetireSuccessAction) \* and don't ignore forever:
∧ WF_vars(PrepareReplay) \* retirement, replay preparation,
∧ WF_vars(ReplaySynchronousSuccess) \* the replay write,
∧ WF_vars(AppendStable) \* head streaming,
∧ WF_vars(CompleteAppendOnly) \* and head commitment.
\* Weak fairness: an action enabled forever is eventually taken. Failures
\* and exits are NOT fair -- they may happen, but are never forced.
\* =========================================================================
\* Invariants (checked by TLC in every reachable state).
\* =========================================================================
TypeOK ≜ \* T: every variable in range
∧ c ∈ 0‥N \* frontier within block ids
∧ phase ∈ [Blocks → Phases] \* valid phase per block
∧ mode ∈ [Blocks → BlockModes] \* valid mode per block
∧ want ∈ [Blocks → SnapshotValues] \* speculation from the universe
∧ final ∈ [Blocks → SnapshotValues ∪ {NoFinal}] \* final or the sentinel
∧ emitted ∈ [Blocks → 0‥MaxSnapshotLength] \* emitted counter bounded
∧ alloc ∈ [Blocks → 0‥H] \* painted heights bounded
∧ target ∈ [Blocks → 0‥H] \* requested heights bounded
∧ history ∈ Seq(TaggedRows) \* ledger rows well-formed
∧ native ∈ Seq(NativeRows) \* native rows well-formed
∧ width ∈ WidthValues \* geometry in range
∧ height ∈ 0‥H
∧ resizes ∈ 0‥MaxResizes \* resize budget respected
∧ epoch ∈ 0‥MaxResizes \* epochs only at resizes
∧ replayMode ∈ ReplayModes \* replay state in range
∧ replayCursor ∈ 0‥(N + 1) \* (loose bound; really 0 or 1)
∧ replayEnd ∈ 0‥N
∧ replayPartial ∈ 0‥MaxSnapshotLength
∧ replayPrepared ∈ BOOLEAN
∧ replayCut ∈ 0‥MaxFailureRows \* cut bounded by max batch size
∧ flush ∈ BOOLEAN
∧ shutdown ∈ BOOLEAN
∧ running ∈ BOOLEAN
∧ stopReason ∈ StopReasons
LifecycleShape ≜ \* LS: blocks form three bands --
∧ c ≤ CreatedCount \* can't commit the uncreated
∧ ∀ i ∈ 1‥c : \* band 1: 1..c
∧ phase[i] = "Committed" \* all committed,
∧ mode[i] ∈ {"Mutable", "AppendOnly"} \* with a declared mode
∧ ∀ i ∈ (c + 1)‥CreatedCount : \* band 2: live blocks
∧ phase[i] ∈ {"Queued", "Active", "Finalized"}
∧ mode[i] ∈ {"Mutable", "AppendOnly"}
∧ ∀ i ∈ (CreatedCount + 1)‥N : \* band 3: not yet created
∧ phase[i] = "Absent"
∧ mode[i] = "Undeclared"
SnapshotDiscipline ≜ \* SD: finals exist exactly for
∀ i ∈ Blocks : \* finalized/committed blocks,
IF phase[i] ∈ {"Finalized", "Committed"}
THEN ∧ final[i] ∈ SnapshotValues \* are real snapshots,
∧ final[i] = want[i] \* and equal the last speculation
ELSE final[i] = NoFinal \* everyone else: the sentinel
EmissionDiscipline ≜ \* ED: streaming is head-only --
∧ ∀ i ∈ Blocks :
∧ emitted[i] ≤ Len(want[i]) \* never emitted more than exists
∧ (mode[i] ≠ "AppendOnly" ⇒ emitted[i] = 0) \* mutable blocks never stream
∧ (emitted[i] > 0 ⇒
∧ i = c + 1 \* only the HEAD may have
∧ phase[i] ∈ {"Active", "Finalized"}) \* streamed rows, and only live
∧ (PartialHeadExists ⇒ emitted[c + 1] ≤ Len(want[c + 1])) \* (redundant safety belt)
Capacity ≜ AllocationStateOK(alloc, target, phase, final, emitted, width, height)
\* CAP: the reservation invariant holds of the ACTUAL alloc/target at all times
ExactCommittedHistory ≜ history = CommittedRows(c, final) ∘ PartialHeadRows
\* ECH, the central equation: the ledger IS the committed finals in block
\* order, plus the head's streamed prefix -- no dupes, no gaps, no reorders
NoPrematureHistory ≜ \* every ledger row is owned by
∀ j ∈ 1‥Len(history) :
LET owner ≜ history[j].owner IN
∨ ∧ owner ∈ 1‥c \* a committed block, or
∧ phase[owner] = "Committed"
∨ ∧ PartialHeadExists \* the streaming head --
∧ owner = c + 1 \* speculation NEVER leaks
ScreenCapacity ≜ \* the screen is exactly right:
∧ Screen ∈ Seq(Cells) \* well-formed cells,
∧ Len(Screen) = height \* exactly `height` of them,
∧ ∀ i ∈ Blocks :
Cardinality({j ∈ 1‥height : Screen[j].owner = i}) = alloc[i] \* each block owns alloc[i] rows,
∧ Cardinality({j ∈ 1‥height : Screen[j] = OverflowCell})
= SummaryRows(phase, final, emitted, width, height) \* the summary row appears iff overflowing,
∧ Cardinality({j ∈ 1‥height : Screen[j] = BlankCell})
= height - AllocationTotal(alloc, 1)
- SummaryRows(phase, final, emitted, width, height) \* the rest is blank -- accounts balance
ReplayShape ≜ \* RS: replay bookkeeping is sane
∧ (replayMode = "None" ⇒ \* idle: all replay state zeroed
∧ replayCursor = 0
∧ replayEnd = 0
∧ replayPartial = 0
∧ ¬replayPrepared
∧ replayCut = 0)
∧ (replayMode ≠ "None" ⇒ \* in flight: window is 1..replayEnd
∧ replayCursor = 1
∧ replayEnd ∈ 0‥c \* over COMMITTED blocks only,
∧ replayPartial ≤ MaxSnapshotLength
∧ IF replayPrepared
THEN ∧ replayCut = RequiredReplayCut \* prepared: the sampled cut is
∧ Len(PreparedReplayTail) ≤ ReplayRoom \* still exact (the gate!) and
ELSE replayCut = 0) \* the tail fits the blank region
NativeSourceSafety ≜ \* NSS: provenance never lies --
∀ j ∈ 1‥Len(native) :
LET owner ≜ native[j].owner IN
∧ (native[j].source = "Retire" ⇒ \* Retire rows: from blocks that
∧ owner ∈ 1‥c \* really are committed
∧ phase[owner] = "Committed")
∧ (native[j].source ∈ {"Append", "Replay"} ⇒ \* streamed/replayed rows: from
∧ owner ∈ Blocks \* committed blocks or the
∧ (∨ owner ∈ 1‥c \* append-only head -- never
∨ ∧ owner = c + 1 \* from mutable speculation
∧ mode[owner] = "AppendOnly"))
∧ (native[j].source = "FailedWrite" ⇒ stopReason = "WriteFailure") \* failure rows only after failing
∧ (native[j].source = "Exit" ⇒ ¬running) \* exit rows only after exiting
\* =========================================================================
\* Temporal (action and liveness) properties.
\* =========================================================================
HistoryExtension ≜ Prefix(history, history') \* one step never rewrites the ledger
HistoryMonotonicity ≜ □[HistoryExtension]_vars \* ... in ANY step: append-only forever
NativeEpochStep ≜ \* per step, native either
IF epoch' = epoch
THEN Prefix(native, native') \* grows at the end (same epoch)
ELSE ∧ epoch' = epoch + 1 \* or is wiped exactly when the
∧ native' = ⟨⟩ \* epoch increments (Rebuild)
NativeEpochDiscipline ≜ □[NativeEpochStep]_vars \* holds of every step
FinalsStayFixed ≜ \* finals are immutable:
∀ i ∈ Blocks :
phase[i] ∈ {"Finalized", "Committed"} ⇒ final'[i] = final[i]
FinalImmutability ≜ □[FinalsStayFixed]_vars \* once frozen, frozen forever
AppendOnlyPrefixStep ≜ \* the append-only contract as
∀ i ∈ Blocks : \* an action property:
(mode[i] = "AppendOnly" ∧ phase[i] ∈ {"Queued", "Active"})
⇒ Prefix(want[i], want'[i]) \* want only ever extends
AppendOnlyMonotonicity ≜ □[AppendOnlyPrefixStep]_vars
ResizeKeepsLogicalHistoryStep ≜ \* resize logical-neutrality:
(width' ≠ width ∨ height' ≠ height) ⇒ \* a geometry change moves
∧ history' = history \* NONE of the semantic state --
∧ c' = c \* not the ledger, not the
∧ mode' = mode \* frontier, not modes,
∧ want' = want \* speculation,
∧ final' = final \* finals,
∧ emitted' = emitted \* or streamed counters
ResizeKeepsLogicalHistory ≜ □[ResizeKeepsLogicalHistoryStep]_vars
FailedWriteStops ≜ □( \* fail-stop: a write failure
stopReason = "WriteFailure" ⇒ ¬running \* and a live host never coexist
)
StoppedStep ≜ ¬running ⇒ UNCHANGED vars \* a stopped host is frozen:
StoppedQuiescence ≜ □[StoppedStep]_vars \* every later step stutters
AllFinalized ≜ \* every created block is done
∀ i ∈ 1‥CreatedCount : phase[i] ∈ {"Finalized", "Committed"}
AllCommitted ≜ \* everything retired, and the
∧ c = CreatedCount \* ledger is exactly the
∧ history = CommittedRows(c, final) \* committed finals
FlushLiveness ≜ \* drain guarantee: finalized +
(AllFinalized ∧ flush ∧ shutdown ∧ running ∧ ¬Replaying) \* flushing + shutting down
↝ (AllCommitted ∨ ¬running) \* eventually fully commits (or halts)
ReplayLiveness ≜ (Replaying ∧ running) ↝ (¬Replaying ∨ ¬running)
\* every replay eventually drains (or the host halts trying)
QueuedDemand ≜ ∃ i ∈ Blocks : phase[i] = "Queued" \* someone is waiting for space
QueuedPressureRetirement ≜ \* pressure + queued demand
∀ i ∈ Blocks : \* eventually sweeps a finalized
(∧ running \* head block into history:
∧ ¬Replaying
∧ c = i - 1 \* i is the head,
∧ phase[i] = "Finalized" \* it is done,
∧ Pressure \* space is scarce,
∧ QueuedDemand) \* and someone needs it
↝ (c ≥ i ∨ ¬running) \* => i eventually commits (or halt)
\* NB: this needs MaxLive small enough that queued demand implies
\* PERSISTENT count pressure; pure row pressure alone can evaporate
\* (see the paper's sharpness remark)
====