← 返回 关于

Harness 设计手册

2026-09-12 · 原文链接

首先,想说声谢谢。数十万用户使用过 omp,报告故障、提出需求,共同塑造了它今天的模样。这篇文章和 omp² 本身,都因你们而存在。

听说 omp² 之后,很多人马上问:“可是,为什么要重做?”

给 fetch 套一个 while 循环,听起来很简单。但 OpenCode、Pi、OpenClaw 和 omp 都在同时进行全面重构,是有原因的:这类软件以前并不存在。只有从简单版本起步,我们才能看到裂缝,进而找到更好的实现方式。

无法避免的复杂性,必须有人负责。现在,复杂性守恒的天平偏向了扩展和用户,让人在 omp 或 Pi 之上几乎无法编写可靠的软件。我已经听到有人说:“什么?它的扩展机制明明又简单又好用。” 给我几章的篇幅,让我试着改变你的想法。

Dijkstra 写过,“简单是可靠的前提”,可他又以用算法解决寻路问题而闻名。为什么不直接暴力搜索?他根本不是在主张我们如今反复念叨的简单就是好,复杂就是坏。那句话是为了帮助实现者推理。可我们却令人惭愧地拿它当借口,让实现者不必推理。

Ousterhout 在斯坦福的讲义中补上了另一半。他告诉模块作者,要“拥抱痛苦”:接下难题,把它彻底解决,再让其他人轻松使用成果。把复杂性压到模块内部。让少数实现者承担它,而不是让每个调用者各自承担一份更小、又略有差别的副本。


不少读者应该还记得,那条把 Claude Code 比作游戏引擎的推文引发了一波梗图。这种比喻听起来有点牵强,但如果列出智能体运行框架(harness)的职责,先把渲染放到一边,其实相当吻合。

它维护一个权威的世界状态,把变更记入日志,执行不可信操作,将状态复制给多个视图,调度参与者,解释命令,适配互不兼容的协议,并渲染实时界面。

是不是很熟悉?游戏引擎似乎已经为这些同类复杂性负责了几十年。

接下来的内容,既是复盘,也是设计手册:

  1. 设计边界
  2. 状态
  3. 运行时
  4. 控制平面
  5. 模型推理
  6. 工具接口
  7. 界面
  8. 技术栈
  9. 结语
  10. 官方示例中的状态故障
  11. 弹性推测槽位

设计边界

在讨论智能体运行框架的任何子系统之前,先想象四种截然不同的产品都要依赖它:

这不是市场用户画像,而是架构测试。它们共同覆盖了那些会让运行框架不再只是聊天循环的维度:

测试场景 本地或远程 交互或自主 信任边界 并发
多智能体共享工作区 本地 交互式 大体可信 多个智能体,一个工作区
远程操控端 远程 交互式 主机与客户端分离 一个或多个智能体
旁观端 远程视图 只观察 不可信的展示输入 多名观看者
Factorio 远程或集群 自主运行 恶意仓库与工具输入 多个作业

只适用于第一种场景的设计,往往会把控制器偷偷塞进 TUI,把状态保存在闭包里,让扩展在引擎进程中执行,并假设一个没有资源或时间上限的调用出了问题,人总能救场。要同时经受住四种场景,设计就不得不建立更好的边界。

本手册余下的内容围绕五个推论展开:

  1. 唯一的权威会话。 回退、分叉、恢复、复制和检查,都必须从同一份记入日志的状态派生。
  2. 可信的控制平面。 策略和会话所有权留在主机;沙盒只接收有明确限制的执行请求。
  3. 有界工作。 工具调用、子智能体和后台作业,都应是可取消的流,拥有集中的限制与可观测性。
  4. 显式兼容性。 模型和提供商的特殊行为,应成为结构化知识,而不是散落在调用点的分支判断。
  5. 视图只是投影。 TUI、Web 客户端、远程客户端和子智能体检查器渲染同一份状态,而不是成为额外的权威来源。

这些约束贯穿全文。后面提出 DOM、convar、Director、微型 VM 桩程序或组件渲染器时,都是为了解决这五项需求中的某一项,而不是为了炫技去引入子系统。

第一项是基础:在决定代码在哪里运行、如何渲染之前,运行框架首先得知道什么才是真实状态。

状态

哪些东西必须保留下来

如果希望某个东西能持久保存、回退、容忍崩溃并支持分叉,你有三种选择:

  1. 保存产生它的历史。
  2. 保存你关心的属性变化。
  3. 保存机器本身。
"You need to serialize state" meme, three panels: event sourcing (crying wojak buried in events, replay everything), incremental snapshotting (calm wojak diffing two property snapshots), and gigachad sourcing (diff WASM memory, restore the machine).

Source Engine 在网络同步中使用了第二种方案的变体。omp 和 Pi 目前则……没有一致地采用其中任何一种。它们有事件,但状态并不真正源于这些事件,违背了事件溯源的首要原则:仅凭事件就必须能够推导出状态。

omp 给我们的教训:两个权威来源

π · two authorities1 · never a delta — but itIS state2 · rewind cannot reach it

OUTSIDE THE TREE
todo · retry · subagents · streaming
closures · prompts · tools · settings · MCP
authoritative, not derived

UNIT OF CHANGE
message · custom · custom_message
covers the tree only

SOURCE OF TRUTH
message tree
ids and messages only

ON DISK · .jsonl
the tree, and nothing else

REPLAY
move leaf pointer

replay(.jsonl) ≠ original
rewind · fork · resume all lie

Source engine · single authoritynever a delta — fine, it isderived

OUTSIDE THE ENTITY LIST
client prediction
derived, never authoritative

UNIT OF CHANGE
{ Δ entity … }
covers every field

SOURCE OF TRUTH
entity list
rules · plugins · globals — all of it

ON DISK · .dem
all of the state

REPLAY
seek tick, re-derive

replay(.dem) == original
nothing outside left to leak

One authority versus two: everything in Source is an entity delta, so replay(.dem) == original. Pi's journal covers the message tree only, while authoritative state lives outside it—rewind, fork, and resume all lie.

走到这一步,原因可以理解。在每份日志里重复保存系统提示词和 AGENTS.md 确实浪费;对模板求哈希、保存变量就能解决。而且,这种状态建模方式在 TypeScript 中并不常见,毕竟它并没有真正的运行时类型。

但结果仍然是两个事实来源:

维度 Source Engine Pi 式运行框架
事实来源 就是 entity list。服务端模拟,客户端预测。 消息树加上待办状态、重试计数、子智能体注册表、流式标志,以及其他持久化层看不见的状态
Δ 的单位 { Δ entity ... },覆盖所有字段,因为每个增量都是实体增量 message / custom / custom_message,没有引擎负责的状态归约;每个扩展手写推导逻辑
全局状态 CCSGameRules 是一个单例实体,没有特例 分成三个层次,其中一个能正常工作
插件状态 插件写实体字段,所以状态默认就能网络同步和回放 模块级闭包:let turnCount = 0、new Map()、new Set()
回放 加载 .dem,跳转到某个 tick,重新推导状态 加载 .jsonl;叶节点指针移动,其他权威来源却任意重置或保留

全局状态那一行尤其有意思。Source 没有会话全局变量,它们只是实体的属性。我们的全局状态却有自己的一套层级:

journaled as tree entriesjournalable via customentriesnot journaled at all

is this fact in the tree?

A ✓ the blessed ~3
model_change · thinking_level_change
session_info · label
replays correctly

B ~ hand-rolled
every extension writes its own derive
≈15 lifecycle bugs, see below

C ✗ outside history
AGENTS.md · extension set · tool roster
settings · provider config · MCP servers

edit AGENTS.md → the replay uses today's copy.
the session you recorded is gone.

header line = (version, id, timestamp, cwd)
no parentid → not in the tree →
can't branch, can't rewind

The three tiers of session globals, one of which works.

Source 的正确性,并非来自精心编写的协调器或出色的文档。它让不可回放的状态根本无法表示。正确性来自这个约束,而不是寄希望于每位扩展作者都记得注册两个钩子、定义一种更新结构。

证据:API 把正确性变成了可选项

我们检查了 Pi 的 78 个官方扩展示例。其中 60 个无状态;17 个有状态的示例里,只有两个是正确的。(译注:原文总数为 78,而上述两类合计为 77,未说明余下一个示例的归类。)

示例 脱离权威管理的状态 用户可见的故障
git-checkpoint.ts 检查点引用由临时 Map 持有 /fork 执行前,agent_settled 已经清空检查点
plan-mode/index.ts 从整个文件而非选中分支恢复计划模式 回退后限制仍生效;恢复会话可能让已废弃分支复活
status-line.ts 轮次计数保存在闭包里 从第 3 轮回退到第 1 轮后,下一轮显示第 4 轮;恢复会话则从零开始
dynamic-tools.ts 运行中的扩展注册表 工具在回退后仍存在,恢复会话后却消失
snake.ts 恢复时扫描了废弃分支 废弃分支的存档重新出现
bookmark.ts “最后”按文件顺序定义 给废弃分支上用户看不到的助手消息加了书签
kimi-deferred-tools.ts 没有重新推导活跃工具列表 回退到发现之前,Calculator 仍处于活跃状态
auto-commit-on-exit.ts 关闭事件混淆了进程退出和会话切换 /new、/resume 或 /fork 会提交工作树
tic-tac-toe.ts 实时写入与恢复读取使用不同条目类型 崩溃可能让用户的一步棋消失

细节见附录 A。重点在于,文档无法修复这样一整类分散的缺陷。引擎需要让状态只能存在于一个地方。

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"]]}

树是权威来源,日志保存树的增量变更。运行时对象可以缓存它、为它建立索引,但不能成为第二个保存事实的地方。在日志的任意位置,运行框架都能物化整个会话,因此也能为整个会话生成快照。

唯一权威来源带来了什么

状态与会话记录放在同一棵树中后,几个难题就都归结为同一种操作。

回退就是 DOM diff。 对当前物化状态和目标状态求差异。一个 <subagent> 元素消失了?销毁元素,终止它。出现了一个?创建元素,恢复或启动它。增量本身,就是完整的生命周期待办清单。

新增有状态功能,不会给回退、分叉、恢复或复制增加任何调用点。

提示词成为投影。 不再需要给每个模板传入一个长达 100 行的状态对象。系统提示词读取的,是与其他部分相同的那棵树:

- {{ count(select("todo item[status!=completed]")) }} open items

复制成为订阅。 应用逻辑和状态推导已经具备了。远程客户端消费补丁流,而不是追踪文件尾部。远程操控端和旁观端不再需要单独的状态传输管线。

渲染成为投影。 组件注册表可以根据相同的元素状态,渲染 Read、Bash、消息或子智能体。流式参数修改 <input>,流式输出修改 <result>。第七章会把它落成类型化界面,而不是又一个定制渲染器。

控制器与参与者

这种分离也让子智能体可以被检查。Pi 的视图直接读取实时会话状态——页脚调用 sessionManager.getEntries()——因此,要新增“检查子智能体”,就得把控制器状态穿过 UI 内部层层传递。

让控制器和参与者彻底分离:控制器拥有会话状态;参与者只渲染状态快照及补丁流。TUI、远程客户端和子智能体检查器成为平等的客户端。检查子智能体,只需要把同一个参与者指向子会话的状态。

忠实的状态模型是基础,但如果不可信代码掌控了修改状态的策略,这个基础依然会被破坏。下一章将划定运行时边界。

运行时

状态一章确立了运行框架认定的事实。运行时一章决定谁能修改它、不可信工作在哪里执行,以及当执行可能持续数小时、流式输出、甚至无视礼貌的停止请求时,“工具调用”究竟意味着什么。

沙盒应该执行,而不是决策

从设计边界里的 Factorio 场景出发。假设我们克隆 roboomp,让 gpt spark 把所有名字替换成 CodeWhatever,然后凭这套神奇技术向用户收取成千上万的费用。工具由谁运行?当然是 VM。嗯,真这么简单吗?

把执行器放进 VM 后,会发生这些事:

TOOLS ARE COMPLICATEDDRIVERtrusted harnesstrustboundaryVMuntrusted: code exec,web contentWHERE DOES EACH TOOL LIVE?1) TODOharness state2) FILE R/Wwhich side?3) IMAGE-GENoutput lands here4) PY-EVALexec stateneeds secretPROGRAMMATICTOOL USAGECONFLICT!?

嗯,这行不通。因为:

好吧,那把驱动应用也放进 VM!

WHAT IF THE DRIVER LIVES IN THE VM?VM (untrusted)DRIVERapp source +promptsnow you haveto build & hostTHISLLMGATEWAYproxy —key staysout here(a)connerror(b)OOMkamikazeoutside can't tellwhich!?untrustedpromptapp sourceleaks out!moved the boundary, kept the pain

解决办法是:在 VM 里只放一个听命行事的桩程序,并且非常谨慎地限制回传数据流的最大体积——你不会想让一次误用的 Read 调用返回 2GB 响应:

THE STUB STAYS IN, EVERYTHING ELSE STAYS OUTHOST(trusted)DRIVER<git>HARNESSLLMGATEWAYkeys live hereSESSIONSTORAGEsingle writertyped RPCthe only doorVM (untrusted)if popped: attacker getsa stub, python, and grepEXECUTORSTUBpy + rgmirrored<git>RO overlaythat's it.nothing else.minimum viable prisoner

这些图最终指向同一条边界:

这种安排满足 Factorio 场景,也不会让本地使用变差。同一个主机可以让桩程序面向本地进程、容器、VM 或远程机器。

子智能体跨越的是同一条边界

部署位置不只是主机与 VM 的区别。子智能体在文件系统层面也需要相同边界:worktree 只隔离受跟踪文件,而 pi-iso 利用 APFS、btrfs、ZFS、overlayfs、ProjFS,或以复制作为后备方案,为每个子智能体提供整个工作区的写时复制视图。子智能体独立修改,父智能体接收 diff。

子智能体获得视图,返回变更,不共享父智能体的可变权威状态。这是同一条主机/沙盒规则在文件系统层面的体现。

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 为例:

这种直觉式实现带来了:

要提高效率,就得在这个定义之外实现并驱动一个协程,找地方保存其句柄,而且整套结果反序列化逻辑仍然跑不掉。

问题不只是代码重复。这个契约缺少一个权威对象,来承载从“参数流式传入”到“运行中”再到“已结束”的状态演进。每种实现都得为这套生命周期发明一条旁路。

omp² 的改变:执行就是状态流

此外,也没有通用方式添加结构化警告、诊断或截断通知。大多数 Pi 工具最终都会做类似的事:

text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`;

模型只能猜测工具数据在哪里结束、运行框架的说明从哪里开始。由于 execute 不是生成器,流式输出又需要在更新通道上叠加一套协议。

DOM 模型消除了这两种特例:

执行期间,客户端接收这个状态的补丁。执行结束时,把相对上一状态的最终 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>

执行器在运行时修改这个元素。模型、用户、日志、远程客户端和测试框架,观察的是同一状态的不同投影。执行结束后冻结最终 diff;客户端不再需要解析结果字符串,去重建序列化之前原本就存在的丰富对象。

限制是原语的一部分

Pi 的工具没有限制:返回 1 MB 文本,它就会原样转发给模型。暴露这样的原语,层次太低了。

统一限制输出

Pi 自己在 Bash 和 Read 上也遇到了这个问题,于是导出一个截断工具函数,供各实现复用。omp 为它增加了产物系统,让模型可以读回保留的完整输出;但责任仍然和 Pi 一样,落在每个实现身上。

向模型发送 1 MB 数据,也许值得保留为一种能力。但应该由集中实现默认截断,允许通过显式的 notrunc 属性退出,而不是让截断这种良好设计成为主动选择。辅助函数保持可选,会带来两类问题。

大多数工具都需要某种截断,所以可选辅助函数必然覆盖不均:

在工具实现内部而不是对话渲染层截断,又会破坏 Code mode:

统一限制阻塞时间

把任何东西转入后台、限制调用最多阻塞多久,也应由库层负责,而不是由碰巧运行较久的工具各自处理。

第一个原因是缓存和用户体验。否则,意外的长调用会让智能体无法及时发现并调整;用户回来时看到卡住的会话;自主作业无限等待;提供商的 KV 缓存甚至会在调用返回前过期。

第二个原因是重复实现,omp 也犯了这个错。每个工具自己实现后台运行,就会各自长出 spawn、poll、message、kill 和 list 辅助接口。看看 Claude 为自己的 Task 和 Bash 工具画的这张图:

Background BashInterfaceSubagentBashrun_in_background: trueSpawnTaskprompt, subagent_typeBashOutputpoll by bash_idStream outsystem-reminderasync agent notificationstdinno tool exposedMessage inSendMessageagent_id, messageKillShellshell_idStopinterruptcancel_queued: trueexit code + tailfinal BashOutputResulttool_resultTask result block/bashesrunning shellsListListAgentsrunning agentsclaude's map of its own tools

两者最终都收敛到进程接口:signal + stream in + stream out。后台 shell、子智能体、开发服务器守护进程、远程函数,以及超出预算的普通调用,其实都是同一种对象:带 stdin、stdout、退出状态和信号句柄的作业。应由一个具有 stdio 形态的作业原语封装它们。这样,阻塞预算在一处执行,溢出输出写到统一产物路径,检查、发消息或终止任何作业都使用同一个接口,而不是每个工具各抄一份。

对可观测性的期待也相同。想看子智能体状态的用户,也想看后台 shell。在不同运行框架实例间互相发消息的智能体,也想看到对方运行的守护进程,这样同一目录中的 N 个智能体就能共享一个支持 HMR 的 bun dev,而不是在 N 个端口启动 N 份副本。

取消需要可强制终止的边界

扩展——也就是自定义工具——与引擎共享 JavaScript isolate,会酿成灾难。真正的热重载几乎不可能实现;工具调用一旦脱离协作式取消机制,就无法被强行停止。

JavaScript 和 Go 通过 AbortSignal、context.Context 提供取消机制:这些协议很有用,却没有强制执行力。忘记传递信号、调用不接受信号的依赖、执行同步工作,或进入无限重试循环,都会让超时仅仅告诉智能体继续往下走,而原来的工作仍可能在后台消耗资源。

因此,安全的主机必须拥有真正可以终止的执行单元——进程、worker、子解释器、VM 请求,或其他等效边界;终止它不能连会话权威状态一起毁掉。取消属于运行时契约,不能靠每个工具作者自觉。

让必要的边界好用起来

刻意保持简单的沙盒桩程序,还带来最后一个 SDK 问题:扩展作者现在面对两个文件系统。一个自定义编辑函数,可能不得不从一边读取文件,完整传输到另一边,再写回去。

这就是 omp² 为扩展选择 Python 的原因。Python 可以通过标准库检查自己的 AST,打包函数所需源码,再提交给另一个运行时;@remote 标记可以把看起来像本地函数的调用变成 RPC。Modal 的 Python SDK 等系统中,远程函数之所以自然好用,也是因为这个特性。

自带 Python 运行时,还让 Eval 变得可靠,不必依赖用户恰好安装了哪个解释器。一举两得。

工作有了可信的负责人和可取消的执行原语之后,运行框架仍然需要一套一致方式来控制配置值和多轮行为。这就是控制平面。

控制平面

运行时负责两种不同控制。配置值回答当前使用哪个模型、服务档位、主题或策略;行为回答智能体是否可以交还控制权、是否必须再运行一轮,或是否临时需要某项能力。如果每个调用者都维护私有 setter 或标志,两者都会失去一致性。

配置值:在定义设置时声明策略

配置系统也变成了雷区:脏状态跟踪,多层配置——全局、会话级、临时级……大多数 get/set 操作像 Pi 一样,都经过 AgentSession 类型,因为变更必须持久化到 JSONL。

知道哪个配置系统多年前就解决了这些问题吗?没错,Source Engine!

尤其值得一提的是,玩过 Valve 游戏的人,大多不用查就知道 sv_cheats 做什么。大家自定义配置这么多年,我想不起有什么用户对此不满。其他软件的配置,你能想出类似例子吗?

convar 是一个带类型的变量,包含名称、默认值、帮助文本和一组标志位,在定义处声明一次:

ConVar sv_gravity("sv_gravity", "800", FCVAR_REPLICATED | FCVAR_NOTIFY, "World gravity.");

持久化、所有权、作用域、复制,甚至回放是否忠实,都是变量的属性,从定义之初就说清楚。没人需要通过一个上帝对象执行 set,也没人手写脏状态跟踪。

FLAGS, NOT PLUMBINGConVar("sv_gravity", "800", REPLICATED | NOTIFY, "World gravity.")SERVER(one authority)sv_cheats 0sv_gravity 800mp_friendlyfire 0sv_password •••REPLICATED NOTIFYREPLICATED NOTIFYREPLICATEDPROTECTEDCLIENT(every player)sv_cheats 0sv_gravity 800cl_interp 0.031r_drawothermodels 1name "can"read-onlyread-onlyARCHIVECHEATUSERINFOlocked unless sv_cheats = 1REPLICATEDforced onto every clientUSERINFO · sent up.demevery change stamped into the demo,replay stays honestconfig.cfgARCHIVE vars written to disk,everything else is ephemeralNOTIFY = change announced to every player     PROTECTED = value never leaves the serverset() through a god object + dirty tracking  ->  flags where the variable is bornone store. flags decide the rest.
One authoritative server store, mirrored to every client. REPLICATED pushes values down, USERINFO sends client-owned vars up, CHEAT locks vars behind sv_cheats, ARCHIVE decides what reaches config.cfg — and every change is stamped into the .dem.

convar 不是会话 DOM 旁边的第二个配置数据库。会话级 convar 就是权威树上另一个记入日志的节点;其标志声明它如何参与恢复、回退、派生子会话、复制和归档。

继承不该需要第二个设置

如今 omp 的服务档位,也就是 /fast,专门给子智能体设了另一项配置。

tier:
  openai: priority
  subagent: inherit   # separate setting

在 convar 体系里,ai_fastmode 只有一个变量,标记为 SESSION:随会话写入日志,恢复时自然恢复其值。继承甚至不需要标志:默认情况下,新建子会话的所有变量都以父会话当前值初始化,无需主动启用。

想把子会话固定到特定值?一行就够:

# 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 都能充当配置方案;每次派生子会话自动执行 subagent.cfg,再叠加 <agent>.cfg。顺便解决了那个拥有一千个属性的上帝对象。TF2 早就知道该怎么做!

现在,一个值就能描述主会话及其子会话。继承规则位于变量定义处,不再成为不断膨胀的会话上帝对象上的又一个属性。

配置方案和快捷键沿用同一命令通道

有了 cfg,再加上绑定就更好了——bind、toggle 和 alias 本身也是控制台命令。我们不断为之发明 schema 的各种输入模式,都能留在同一命令体系里。用户想绑定一个隐藏思考内容的快捷键?

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 文件、控制台输入、别名、按键绑定、远程管理和日志回放,都围绕同一批声明变量,使用同一种语言。定制不再催生一套又一套一次性 schema。

行为:循环形状的缺口

另一个值得关注的话题是可扩展性。我认为 Pi 的扩展层确实很出色,但它有一个“循环”形状的缺口。

我安装了 Pi 中最流行的 Plan 和 Goal 实现,同时启用两者,结果是:

Pi status line showing: Warning: Another workflow is active in this session. End it before starting Plan mode.

好吧!有意思,可这里并没有“工作流”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 手写的模式检查也有同样的局限。

由此得出两个决定:给负责控制循环的原语一个名字——Director——并把更多内置行为移到公共扩展接口上,让接口的缺口无法再被忽视。

Director 负责裁决候选交还点

智能体有一个循环。越来越多的功能想指挥这个循环:plan 希望在计划完成前再运行一轮,goal 希望在目标完成前再运行一轮,/force 希望改变下一次模型推理,待办提醒希望在交还控制权之前再获得一次提出异议的机会。

那就给智能体层一个统一负责这项决策的对象:Director 栈。

这里的“栈”指会话 DOM 中一棵实时子树,不是一个承诺以后再序列化的 Python 数组。DOM 才是权威来源;运行时只是遍历它。

candidate yield flows this way ────────────────────────────────┐
                                                               ▼
Base  →  TodoReminder  →  Goal  →  Plan  →  ForceTool(write)
                                                parent    child/top

循环依旧平平无奇:

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 可以:

因此,回退会移除 Director,恢复会话会还原它们,远程检查器也能看到当前由哪个行为负责裁决候选交还。

完整实现计划模式

假设计划模式已经启用,模型还没写计划文件就想交还控制权。Plan 会先于任何外层行为看到这次候选交还:

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

候选交还先到达 Plan。Plan 活跃时,要么继续,要么压入子 Director,要么直接把控制权交给用户。它不会 Pass,所以外层 TodoReminder 看不到这次交还。

扩展使用完全相同的接口:

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 负责交还决策,临时压入 ForceTool,在子级结束后重新收到同一次候选交还,再决定继续还是交给用户。

钩子、Director 与模型推理

这足以让 plan、goal、vibe、autoresearch、提醒和外部验证行为使用同一个智能体层原语,无需让每种行为了解其他所有行为的私有标志。

ForceTool 表达的是语义请求:“下一次成功的轮次必须调用 write。”它不知道所选提供商有没有原生 tool_choice,不知道强制调用会不会破坏缓存,也不知道本地模型是否需要额外提示。这个转换属于推理层。

控制平面现在能够表达应该发生什么。下一章要让这份要求在互不兼容的模型和提供商之间,仍然具有相同含义。

模型推理

控制平面请求的是语义行为:让这个模型流式输出,强制使用那项能力,约束这种结构,统计这些 token。推理层则必须把请求转换为:这个具体模型,在这个具体主机上,通过这个具体 API,实际能做到的事情。

omp 给我们的教训:特殊行为会变成架构

这点很好解释,因为 omp v1 已经有一个前后对照提交。

在 dd57045396 之前,OpenAI 兼容性逻辑集中在一个 880 行的文件里,围绕一个巨大的构建器展开。打开文件,迎面就是:

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。这个回环地址代表 llama.cpp,还是代理其他服务的 LiteLLM?再加一个特判吧。

单看任何一个分支,都没有错!每个分支都修复了真实的提供商问题。问题是,同一份知识最终被编码到了多个地方:

替代它们的是什么?

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"
        }
    }
}

这才是我们真正想表达的知识!Sonnet 4.6 之前的版本使用预算式思考;Anthropic 4.7 及之后使用自适应思考;而且只对已经验证过的主机作此判断。

KDL 本身并没有魔法。真正让我们避免用更漂亮的格式重建一团乱麻的,是编译器:

这让提供商不再古怪了吗?当然没有。我们仍有 requires-mistral-tool-ids、qwen-preserve-thinking、strip-deepseek-special-tokens 这样的兼容性维度,还有十种“关闭推理”的写法。看看这些名字,真让人想哭。

它免除的,是为下一个怪癖再到四个函数里各加一个分支。现在只需在负责这个事实的地方写一条规则;优先级有歧义,编译器就会大声报错。推理层终于能回答:这个具体模型在这个具体主机上,到底支持什么?

收益不是怪癖更少了,而是每个事实都有唯一负责人、优先级明确,并且当库尚未确定答案时,能表示 unknown。运行框架其余部分,不再靠提供商名称分支反复猜测模型身份。

提供商不只是 stream

我为 Pi 实现 Web 搜索插件时,这个问题几乎注定会回来找我。事实上,同样的压力也冲击了仓库最初的极简设计,Pi 新增的图像模型实现就说明了这一点。

Pi 将提供商建模为 stream 和 streamSimple,基本就这些!这很适合快速接入一个提供商,却不适合在其上不断叠加能力,因为:

你觉得每个做这些事的扩展,都正确实现了同步协调的 OAuth 刷新和重试吗?

此外,能使用推理提供商最新的控制能力,本身就很有价值,例如:

认证刷新、重试、token 计数、搜索、生成、发现和提供商原生控制,都是共享基础设施。把它们留给扩展,就必然会出现同一协议的多个残缺实现。

能力策略:强制工具调用

强制工具调用很好地说明了,为什么“支持一个标志”还不够:

理想的运行框架实现应当:

  1. 始终注入软提示,告诉模型下一轮必须调用该工具。无条件这样做是值得的:OpenAI 等托管 API 会悄悄替你加上这种提示,开源推理引擎却不会。因此,vLLM 后面的模型可能面对一个从未被告知的硬约束,在启用推理时无所适从。软提示抹平了这项差异。
  2. 只有没有额外代价时,才设置原生标志。如果提供商支持强制工具调用且无副作用,就透传;如果有代价,就跳过标志,只依靠软提示。
  3. 不服从时逐步升级。如果模型没有调用工具,就在有限次数内重试;最后手段是在有代价的情况下也设置原生标志。说服失败之后,正确性优先于缓存。
NoYes, side-effect freeYes, but costly(e.g. Anthropic cache miss)YesNoYesNo

Extension requests forced tool call

Inject soft prompt:
"you must call tool X next turn"

Provider supports
native forcing?

Run turn with
soft prompt only

Set native tool_choice flag

Run turn

Model called
the tool?

Done

Retries left?

Retry — escalate:
set flag despite drawbacks

Surface failure to caller

The forced call starts as a soft prompt; the native flag goes on only when the provider supports it without side effects, and if the model still doesn't call the tool, bounded retries escalate to setting the flag despite its cost before surfacing failure to the caller.

这是上一章 Director 在提供商一侧的实现。ForceTool 声明不变量;推理层选择成本最低、又真正满足要求的方式,并在模型不服从时升级措施。

工具 schema 是面向模型的协议

工具的 parameters 字段严格定义参数结构。对人类 API 来说很理想,但模型不是通用 API 客户端。它们的错误往往与工具名、以及训练中见过的运行框架有关。

强化学习优化到极致的智能体,可能拿另一个框架的 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 和 strict 支持,但只把它暴露为供提供商层透传的不透明结构。两项全局约束决定了,这样还不够:

  1. strict schema 容量是共享预算。 许多提供商限制 strict schema 的数量。独立编写的扩展装得足够多,提供商就可能拒绝每一次请求。用户不该为了让框架恢复工作,靠二分排查并修改插件。
  2. 语法方言因提供商而异。 把同一份 LARK 语法传给所有提供商,本身就可能无效。扩展无法维护完整兼容性映射,因为用户可能通过原生主机、代理或自定义提供商访问同一个模型。

因此,这套看起来“复杂”的实现应该属于推理层:

NoYesNoYes, and priority winsYesNo

Extension declares tool as strict

Provider supports
grammar enforcement?

Ship JSON Schema only;
unconstrained sampling +
charitable client-side repair

Budget remaining?

Normalize schema
per provider dialect

Inject grammar constraint
on the wire

Run turn

Output valid?

Done

Repair client-side,
surface structured error
to model

Model retries with
correction signal

What honoring strict actually takes: provider capability, a strict-schema budget with priorities, per-dialect normalization, and a client-side repair path—none of which an opaque pass-through struct can provide.

扩展声明意图——严格程度、语法、优先级。推理层负责能力、预算、方言归一化、回退、修复和最终传输格式。

纠错式推理

推理库还需要:

  1. 修复格式错误的 JSON。
  2. 检测 Gemini、DeepSeek 等模型的重复循环。
  3. 解析各模型的输出方言;结构化输出泄漏到文本时,合成为规范的 tool_call 和 think 块。
A leaked tool call rendered as prose in a chat transcript because the model's dialect was not parsed into a tool_call block
由于模型方言未被解析成 tool_call 块,泄漏的工具调用被当作普通文本渲染。

工具调用方面的细节,可以看我的前一篇文章。支持一个提供商或模型,不只是接上 URL,还得处理它的个体怪癖。

提供商适配器能打开流,不代表它已经完整。只有在遇到畸形 JSON、重复、泄漏的推理内容或模型特有的工具调用方言时,框架其余部分仍能收到一个规范轮次,适配才算完成。

压缩应该调度,而不是临界触发

这里,天真的设计恰好也带来了最糟的体验:用户最投入的时候,却要等待整个会话中最大的一次请求。

除了 Snapcompact 这样的方法,还有很大改进空间。

Claude's chat interface showing a spinner, a 43% progress bar, and the text: Compacting our conversation so we can keep chatting. This takes about 1-2 minutes.
连前沿模型实验室交付的也是这种朴素设计。

更好的做法,是在距离上限约 10% 时,提前试探性启动压缩。相当于把对话分成两个并发版本:一边让用户和模型继续工作,另一边由模型压缩对话。

omp's status line: model GPT-5.6-Sol, working directory pi, git branch main, and a context gauge at 3% of a 1M window with two tick marks placed short of the limit.
看到那个层叠图标了吗?它显示了提前启动压缩的时机。

收到压缩结果后,再把它拼接进另一分支。这样还能保持工作势头:模型不会因历史里只剩一条交接消息而困惑,而是能看到那些它本来就应该完成的进展。

除了提示词,还有几种值得考虑的方法:

注意,这也应该体现在 UI 渲染与请求渲染的抽象差异中。用户查看历史时,会希望所有消息仍保持原样;但对模型而言,那些消息已经不存在了。因此,构造请求时,应把提示词历史中的每个条目建模为一个归约步骤 fn(this, req) -> req,并在 <Handoff> 的实现中处理。

用小型本地模型处理框架内部工作

微型本地模型超级有用!即使你只用前沿模型工作,我也建议内嵌一个 tiny 模型,尤其可以看看 LiquidAI 的模型。分类、生成标题、翻译、判断用户对对话进展是否满意等小任务,都能省下大量延迟和费用。当然还有 TTS/STT,本地已经可以获得业界领先的表现。

这不是第二个“智能体”,而是一项低成本的内部能力,用来处理不该支付前沿模型延迟和费用的小任务。

兼容性和修复集中处理后,常驻工具接口就能保持精简。下一章讨论的是:什么值得在每次请求中占据一个 schema,什么绝对不值得。

工具接口

运行时一章定义了工作如何执行,推理一章定义了 schema 如何适配模型和提供商。现在终于可以问产品层面的问题:哪些操作值得占用模型的常驻语法?

每个 schema 都有成本

向模型提供大多数工具的最好方式,是根本不把它们放进常驻工具列表。

前阵子,有人抱怨 omp 在同一个任务上比 codex 慢——不是 token 用量,而是实际耗时。我本以为这没什么实质问题,没想到竟然是真的,甚至接近两倍!

MEDIAN WALL, SECONDS (sol:med)PREFIX, K TOKENS0s20s40s60s80somp · stock, no fixes23 defs · 12–16 turns86.2s25.1k tokomp · todo-batched23 defs, r9 · 6–8 turns59.5s25.1k tok↓ −26.7s · todo-batching fixomp · /xdev default15 defs, r11 · 6–8 turns45.4s20.6k tok↓ −14.1s · 23→15 wire defsomp · lean floor5 tools, r11 · 4–6 turns36.6s15.1k tok↓ −8.8s · essential-5 onlycodex-cli 0.144 (reference)3 tools · 4 turns42.2s9.6–12.3k tokpi (reference)~5 tiny-schema tools · 6 turns37.0s5.6k tok
Median wall (thick bar, seconds) and request prefix (thin bar, k tokens) per variant · task sol, median of 6 runs, fresh session each · cyan = omp variants, grey = external references · annotations are deltas vs the row above.

罪魁祸首是工具列表。限制为五个核心工具后,耗时就变成 36.6s,快于 Codex 的 42.2s 和 Pi 的 37.0s。为什么?工具语法!即使对模型只是文本描述,对大多数前沿模型提供商来说,它也会实际参与 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 自动生成的;光有 schema,就足够生成一套好用的 CLI 映射。

面对大输入时,这种方式尤其方便:

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。

大家应该给工具加版本号。

这样运行轨迹就好用得多:可以解析频繁变化的工具 I/O,评估成功率随时间的变化,而不必猜测每次调用究竟来自哪个版本的契约。

名称、版本、意图、输入、输出、诊断和用量,都是协议数据。一旦轨迹用于评估或修复,对其中任何一项靠猜,都是本可避免的技术债。

功能深厚的内置工具

小型工具列表能成立,前提是原语的广度来自统一语义,而不是把不相关功能塞进同一个 switch。omp 的内置工具就是很好的例子。

Read:把资源转成可用表示

omp 里最无聊的工具,实际上囊括了其他框架可能拆成 20 个工具的能力。

这并不是为了显得聪明而搞多态。从模型角度看,它们全是同一种操作:

把这个资源转成最适合我理解和推理的表示形式。

对于代码,它还能返回结构摘要,用省略号替代大型声明体。模型不必仅为找到类 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 资源、子智能体会话记录、技能、记忆、本地临时空间、omp 文档,甚至通过 SSH 访问的远程机器,都能纳入同一个内部 URL 子系统。我们推荐这种设计。

Read 也会处理不那么显眼的恢复逻辑:依据唯一的工作区路径后缀,修正错误的绝对路径;在 Windows 上展开 ~;避免其他浪费轮次的路径错误。

它能不能只写成这样?

return await Bun.file(path).text();

能。然后扩展作者会自己实现读取器,或者模型寻找 shell 变通方案;与此同时,框架又会用 web_fetch 等不同名字暴露形态相近的能力。

这不是更少的复杂性,而是同样的复杂性被复制到 shell 命令、提示词、扩展和失败的工具调用里。没人负责,每个人都略有不同地实现其中 30%。

Read 复杂,是为了让读取不复杂。

复杂性由一个地方负责。操作保持稳定,资源特定的投影放在其后。

Bash:理解策略的命令语言

Bash 工具不该只是调用外部 Bash。听起来很疯。

omp 在进程内提供完整的 bash 解析器、解释器,以及全套 coreutils。这个选择很好,原因很简单:

当 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 最近的研究也指向同样结论,auto mode——让另一个 Claude 读取命令——明显胜过人类。

omp 自己解释命令时,可以等执行到 ln 的那一刻才询问;之前的操作全是只读。如果用户已允许写入该目录,连这次询问也能省掉。

这让运行框架不再像给“Bash”过安检,而是审批具体能力:“我可以用 Git 推送吗?”find、cat、ln 等常用命令在进程内运行,在需要时才查询访问模型,并继承用户已有的读写策略。

主机解释常用命令,意味着审批可以发生在真正重要的能力边界:git push、工作区外写入、网络请求,而不是一串难以阅读的 shell 字符串边界。第三章的运行时策略由此可以被强制执行,同时保留模型的 shell 肌肉记忆。

AutoQA:给智能体一条报缺陷的路径

我们创建分叉一个月后就加入了这个工具,比 Anthropic 在自家产品中加入类似功能还早。

你通常会提供某个渠道,让用户报告产品问题,对吧?这就是面向智能体的对应版本。它让你完全自动地收集:工具哪些地方好用,哪些地方令人困惑,哪些行为看起来有错。

报告质量确实不算出色。例如,Codex 没有正确重命名东西时,就爱把文件被外部修改的问题归咎于 Read 或 LSP 工具——不是我的错,去问 TypeScript 那帮人。不过,这类报告很容易过滤。过滤之后,就能获得大量有价值的信号,知道哪些工具失败、如何改进。

AutoQA 让工具设计与上线后的实际行为形成闭环。它有噪声,但剔除明显误归因之后,就能揭示:哪个操作让模型困惑,哪种投影隐藏了必要数据,哪些修复该由运行框架承担。

现在,工具有了受限运行时、稳定的发现接口和结构化状态。只是为了安全地显示这些状态,不该还要求每个工具作者——往往就是 Claude——都成为终端渲染和安全专家。

界面

会话 DOM 和工具状态流,让每个客户端获得相同事实。但它们本身并不会自动产生安全、快速、一致的界面。渲染器仍可能把这些事实变成反复解析的字符串、扩展各自的样式约定,以及无法挽回的回滚缓冲区缺陷。

omp 给我们的教训:字符串开销会层层放大

这其实是我最早向 pi-mono 提交的几个 PR 之一所处理的问题。修改之前,如果对 Pi 执行整个任务的过程做性能分析,再查看 CPU 用量榜,榜单几乎全被——你猜对了——渲染器占据!

Pi 会话的 CPU 性能分析,几乎由渲染器主导:自身耗时矩形树图。仅字符串扫描(红色)就消耗了会话的五分之一。原站中可悬停方块查看对应代码。译注:该图为依赖原站脚本的交互式画布,静态译文不包含其交互显示,请前往原文查看。

作为 TypeScript CLI,有些成本难以避免。光是字符串内部使用 UTF-16,就意味着每一帧都得经历相对昂贵的转码,除非你像个狂人一样,用 Uint8Array 到处传递文本。

但让成本层层放大的,是契约本身。想嵌入一个子组件?现在你得处理:

图像还可能以 base64 文本混在某一行里,这就更糟了。仅检查一行是否为图像行的 .includes,就占了整个会话 CPU 周期的 20%。(译注:本章开头的摘要写 13%,此处正文写 20%;原文未解释统计口径差异,译文保留两处数字。)账单可不小——而且这个会话根本没有图像!

这还只是 JavaScript 一侧的图表。在这种设计下,渲染管线成了反复折腾堆内存的机器:不停分配、拆解、丢弃字符串和字符串数组;拼接、切分、截断、填充,每一步都重复。真不妙。

同一契约还让扩展缺少共同设计语言。只要用过任何 Pi 扩展,你就知道,除了让 Clawd 逐个重做样式、再自己维护,没有办法让它们遵循共同规范。

没有契约规定该不该用圆角边框、能不能用 Nerd Font 图标、会不会用你喜欢的颜色表达操作语义。你会发现:

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`));
  }
}

这里的问题不少:

  1. 它按码点而不是可见宽度切文本,因此一旦把终端缩到 40 列以下,就会越出本行,把下面的内容撞乱。
  2. 它不知道终端宽度,所以即便空间充足,仍然给你省略号!
  3. 最重要的是,它无视 Pi 组件的第一条规则,没有清理外部输入。因此,抓取的内容只要送来合适的 ANSI 转义序列,就能把整个 UI 换成一张鸭子图片。当然不可能再干其他坏事啦!

把复杂性推给毫无防备的开发者——往往就是 Claude——自然会出现这种事。

每次被要求“帮忙做个工具 UI”时,LLM 不会记得运行框架的所有内部细节。说实话,有时我也不想记,而冒烟测试依然会判定它可用。

性能、安全和一致性问题,都来自同一个原因:一段已经渲染好的字符串,同时被当作布局树、样式树、内容、传输载体和终端程序。

omp² 的改变:单遍处理原语

最底层的调用者——除非你来提交 PR,否则不是你——把 RichText (Style, String) 推入传给它们的抽象管线 (&mut impl Out)。

这把 267 秒的渲染时间降到了 90 毫秒:

RENDER ONCE, REPLAY FOREVERbeforerender(): string[]string[]string[]'string[]''string[]'''parentstring[]parsewrappadconcatallocallocallocallocN components × M transforms — every buffer re-parsed, re-measured, thrown away. Every frame.afterpush run(style, &str) into a sinkmarkdownlatex · syntaxdecompose(ansi)external text, parsed once.wrap(w).clip(w,'…').restyle(f)single pass · no intermediate row buffersFramediffstdoutANSI written here, once.tee(cache)RichTextpool: Stringruns: [(Style, ..end)]rows: [(run_end, width)]clear() keeps capacity — streaming re-renders allocate nothingreplay()next frame, no re-renderN + N·M buffers per frame  ->  O(cache)
Before: render(): string[] — N components × M transforms, every buffer re-parsed, re-measured, and re-allocated, every frame. After: RichText runs stream through the abstract pipeline in a single pass into the frame diff.

临时对象、ANSI 解析、字素处理:在帧渲染器以下的每一层里,统统消失!显然就该如此。

既然可以流式写入填充,再写一行组件内容,如此重复,为什么还要先给整个组件补齐再往下传?既然可以在省略号之后直接停止消费流,或在变换过程中自行分行,为什么要让你先把 255 行 diff 全部彩色渲染出来,再 .slice(0, 3),只为截成另一个字符串缓冲区数组?

底层原语统一负责测量和变换。高层永远不该解析 ANSI,去重新发现自己刚刚输出的结构。

类型化组件模型

接下来,string[] 会被真正的组件模型替代。高层调用者只需堆叠盒子,让 LSP 为自己指路:

Editor showing omp² component markup where nesting a <text> element inside <text> is flagged: elements are not allowed inside <text>
标记语言具有类型约束:在 <text> 内嵌套元素,会在编辑时触发 lint 错误,而不是到运行时才把画面弄坏。
omp² TUI rendering markup live: a box with a title row, an icon, a horizontal magenta-to-cyan gradient on text, and a rendered LaTeX fraction one half
输入标记,输出画面:<box>、<row>/<col>、<ico:new/> 图标、水平 magenta..cyan 渐变,以及由 $$ \frac{1}{2} $$ 实时渲染的 ½。

我可能不喜欢写前端,但我可太喜欢好的抽象了。只需 (Element, Props, Children),再搭配一个布局引擎,相比之下就已经美妙得多。

DOM 一章承诺,任何参与者都能渲染一个工具元素。下面就是这个承诺的具体样子。

这是 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 客户端、快照测试和远程检查器,决定如何在自己的界面上布局这些结构。

展示策略属于渲染器

组件模型免费带来两个有用特性:

  1. <ico:new/> 为每个插件提供方便的图标,同时尊重用户选择 ASCII、Unicode 还是 Nerd Font。边框也一样。
  2. 语义颜色不再需要把主题对象穿过每个渲染器。Claude 可以请求 info,而不是挑一个字面颜色,再祈祷它适合用户主题。
omp² markup using border=round bc=info and fg=red..blue, rendered as a rounded box in the theme's info color with a gradient glyph
border=round bc="info" 解析为主题中的语义颜色;fg="red..blue" 表示渐变。不需要到处传递主题对象。

文本流的节奏也需要统一管理。Claude 和 Codex 输出分块的节奏很不一样:一个每次几个词,另一个每次几个字符。抹平这些差异,会改变框架给人的响应速度感受:持续运动看起来是在推进,突然喷出一段再停住则不是。嘿。

语义图标、边框、颜色、截断和流式节奏,现在都有唯一负责人。扩展请求 info、error 或 <ico:new/>,不必把主题对象穿过每个函数,也不用替所有用户选择 Nerd Font 字形。

验证是界面的一部分

在当下这套玩法中,投入产出比最高、又几乎不额外花钱的事情,就是让智能体为任何交互式 TUI/GUI 实现调试协议。如果“怎么验证”不明确、没定义,智能体就会另走旁路,做出一个貌似验证的东西——多数情况下,就是新建一个其实没检查什么的测试文件。

提前定义“验证”意味着什么,并提供方便的使用形式,就能大幅降低摩擦,让验证真正成为开发循环的一部分。

Two TUI Debug tool calls: one injects eight synthetic key events into a session named chat, the other dumps the headless layout tree with component names, positions, and focusable flags

具体形式不重要,也随时可以调整:自定义工具、Python 包或 API 都行。但必须提供某种非破坏性、离屏、支持多实例的东西,防止智能体重新定义——通常是降低——成功标准。

换句话说,调试协议成为“这个 UI 是什么”的机器可读定义,而不只是测试辅助工具。

会话记录是一种协议

TUI 真正不可能做到的事,是 GitHub 上没有任何抱怨它坏了的 issue。人们总会对自己不了解的事抱有理想化期待。不幸的是,很多人不知道,他们想要的完美 TUI 体验根本不可能实现:无论组件位于哪里,都永远完全最新,并且能动态修改。

块

我们把规范会话记录定义为一个块列表。每个块生成文本行,并经历以下生命周期:

活跃(active)→ 定稿(finalized)→ 提交(committed)

块 i 存活时,展示当前快照 Wi,它是一个行数组。定稿时,冻结为不可变快照 Fi。

块有两种模式:

当块超过分配给它的视口空间时,这种区别很重要。可变快照不能提前进入历史,因为后续更新可能替换它,那就得把已经滚走的行拽回来。助手思考内容这样的仅追加块,只会扩展稳定前缀,所以该前缀可以立即开始提交。

终端

宽为 W、高为 H 的终端有两个缓冲区:

技术上,我们可以清空并重写回滚缓冲区,但那会导致用户经常抱怨的行为;因此,不这样做现在成为不变量。

折行函数 wrapW 将逻辑行转换为物理行,取决于当前宽度。视口下方没有可寻址区域。写过底部会让终端滚动,并把顶部行不可逆地推入 S。

逻辑历史 L 以未折行的行保存,因此与宽度无关:按块顺序排列的已提交最终快照,每个恰好出现一次,再加上当前流式块已经放行的部分。令 c 为最后一个已提交块,j = c+1:

L = F1 · F2 ⋯ Fc · Wj[1..ej]

其中 ej 表示流式队首块已经写入历史的行数。除非块 j 正在以仅追加模式流式输出,否则 ej = 0。

因此:

调整尺寸

调整尺寸不会改变任何逻辑状态:所有 Wi、Fi 和 c 都保持不变。只重新计算折行和视口空间分配。已经进入原生回滚缓冲区的行不能重写,因此必须为它们明确选择一种策略:

这些规则分开了三种容易混淆的东西:视口内可变的展示、与宽度无关的逻辑历史,以及不可逆的原生终端行。把它们命名之后,调整尺寸和流式输出就成为策略选择,而不是口口相传的玄学。

为不可能的部分写规范

为什么要让你经历这一大段“数学”?因为这个算法极其复杂,很难验证它是否合理。上一次迭代,我们不得不写模糊测试器,才达到稳定状态。这一次,我想避免重走老路。

于是,我们用 TLA+ 对上述行为建模,要求迭代修改块的提交与定稿处理,直到所有明确规定的不变量都满足。

现在,如果真想改点东西,比如放手提交部分内容,或禁止块截断,我们有一份可以更新的参考规范,也有一个极其简单的办法知道修改能否成立;失败时还会给出反例。

论文与完整的 ElasticSlots.tla 源码位于附录 B。

这解锁了什么

例行炫耀一下,然后继续!现在如果有人抱怨 TUI 坏了,我就能给他一个形式化证明,解释为什么它修不了。太好了。

The omp² TUI: a command palette overlay above a list of live worker shards, a session rail with diff stats, a status bar, and an inline image thumbnail
任务执行中的 omp² TUI:实时并行分片上方的命令面板、带逐文件 diff 统计的会话侧栏,以及内嵌图像缩略图——每个元素都是同一条流式管线上的组件。

TUI、Web 客户端和远程检查器可以布局不同,但事实不能不同。工具作者描述语义状态,组件系统负责展示,会话记录协议负责恰好一次的历史。

又是同一个设计动作:把难以维持的不变量下沉到能够强制执行它的层。实现所用的技术栈应该强化这些不变量,而不是邀请每位贡献者——以及每个编程智能体——发明局部风格。

技术栈

前几章讲的是架构。语言选择决定了,当下一个“好心”的局部特例出现时,代码库会在它与架构之间设置多大阻力。当大量实现由智能体生成,而它们学到的是各生态的默认习惯和毛病时,这一点尤其重要。

语言选择就是架构

眼下,除非不得不与前端代码打交道,否则 TypeScript 是个糟糕的选择。

如今启动项目,最有影响力的决定之一,就是选对工具。三年前,要是看到文章这样开头,我大概已经开始吐槽了。但……如果你不信,试着给 Claude 完全相同的提示词,描述一个想做的小组件。

再把 macOS(Swift)换成 Linux(Qt/JS)。前者会给你一个玻璃质感的小组件,看起来天生属于这个操作系统;后者会给你一个矩形,UI 元素互相重叠,交互选择令人怀疑,让你觉得自己刚看完定义 UI 所需的 XML schema,正在第一次尝试编译。

当然,提示方式有影响,你也确实可以写得更详细。但过一阵你会发现,无论怎么做,一边总能几乎不费力地胜过另一边。macOS 历来有件事做得好:强迫开发者遵循一致的设计风格。这对 LLM 同样有效。

重点不是 Swift 自带品味而 JavaScript 没有,而是默认值、标准库、规范项目结构、编译器反馈和生态约定,会成为生成代码的先验。一门允许二十种局部风格都同样正常的语言,等于要求模型还没碰到产品问题,就先做二十个选择。

TypeScript 会变成你自己的语言

遗憾的是,我曾经喜欢 TypeScript 的一点,恰恰是它最后总会变成你自己的语言:

你看,我在人生中最强的只写不读语言——C++——上花了十年,所以确实能从中找到乐趣。但当必须在 Zod 与 Typebox 之间选择时,你那位“初级同事”只会自己写一个所谓的 isRecord。能直接联合类型,为什么用泛型?能加个 typeof 分情况,为什么在脑子里确保每条分支对两种类型都有效?为什么用类,不就是对象和原型吗?

也许是世上糟糕的 JS 代码实在太多,也许是模型训练时吞下了一堆压缩代码,但我已经厌倦了。考虑到同一个“初级同事”还能发现 Linux 零日漏洞,换作是我,就不会再指望对的模型或对的代码质量工具,也不会继续费劲绕圈子。

也许 EffectJS 会改变这一点。我认为最终 Go 会在这里胜出,尤其是 WASM 的 GC 提案定稿之后,原因类似 Swift 在设计上占优的原因,再加上编译速度和交叉编译的便利。不过,有些场景需要更底层的系统语言,所以我们在这里选择了 Rust。

智能体仍经常需要引导,因为它们总走通向目标的最短路径:不处理复杂借用,改为分配副本;不用 thiserror,改为传递字符串错误。但 std 加上 serde 生态,已经提供了它们工作所需的大部分东西,编译器也提供了相当程度的安全保障,于是就这么定了。

扩展使用 Python

下一项决定,是要不要为了可扩展性把 TS 请回来。我们说不,主要因为:

  1. 智能体能写出不错的 Python,顺理成章也就能写出不错的扩展。
  2. 想用很小的体积做出符合规范的 JS 运行时,基本不可能,感谢 Locale;而没有生态,还不如用 Lua。
  3. 扩展占运行时间甚至不到 1%,没什么必要上 JIT。
  4. 内嵌完整 Python 运行时,就能保证 eval 开箱即用,而不是要求用户安装 py3,却始终无法在交付的工作流里可靠依赖它。
  5. Python 代码原生就能检查自己的 AST。这正是运行时一章中 @remote 设计能够成立的原因。

运行时一章介绍了 @remote 边界。Python 的自省和属性模型让这个边界好用:SDK 能检查函数、打包相关源码,并在沙盒运行时执行,不必让每位扩展作者手写 RPC。

自带运行时,也让 Eval 成为可靠的内置能力,而不是只有用户碰巧安装了兼容 Python 才能工作的功能。

结语

开篇的问题是:“可是,为什么?”直接回答就是:上述每一章,都是一类已有数十年经验积累的软件问题——复制、沙盒隔离、配置、调度、协议兼容、实时渲染,以及语言和运行时设计。

omp² 仍在依照这份文档构建,各部分进度从已经上线到仍在思考不等。我们真诚感谢每一位愿意尝试它、并分享各种精彩用法的人:从让 omp 运营软件工厂,到让它直接在所在手机上为自己构建相机应用。

你们塑造了 omp,我们相信未来也会同样有趣!


附录 A:官方示例中的状态故障

状态一章按类别概述了这些故障。本附录保留原始证据:源码链接、最小代码片段,以及复现视频。

这不是理论上的指控。我们检查了 78 个官方扩展示例:60 个无状态;17 个有状态的示例中,只有两个正确。

1. /fork 还没来得及使用,检查点就已被清空:git-checkpoint.ts

源码:缺少持久的检查点所有权;/fork 在空闲时调用,而 agent_settled 已经清空唯一保存 stash 引用的映射。

const checkpoints = new Map<string, string>();
// …
pi.on("agent_settled", async () => {
  checkpoints.clear();
});

2. 会话树导航不恢复状态:plan-mode/index.ts

源码:缺少 session_tree 和 getBranch();回退后计划模式及其工具限制仍生效,恢复会话却可能让废弃分支的快照复活。

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. 切换会话会提交工作树:auto-commit-on-exit.ts

源码:缺少仅用于进程退出的边界;/new、/resume 和 /fork 都会触发 session_shutdown,进而暂存并提交有未提交变更的工作树。

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

恢复逻辑;用户落子:重建只接受工具结果,用户落子却使用 custom 条目;如果在 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:弹性推测槽位

界面一章把协议及结论放在主线内容中。本附录提供论文,以及用于检查会话记录不变量的完整 TLA+ 模型。

First page of the Elastic Speculative Slots paper: a formally verified rendering protocol for streaming concurrent output blocks through a bounded terminal viewport into append-only scrollback
《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)

====