코딩 에이전트 하네스를 게임 엔진처럼 바라보면, 영속 상태·도구·설정·TUI·추론 모두 익숙한 엔지니어링 문제로 정리된다.
먼저 감사의 말씀을 전합니다. 수십만 명에 달하는 여러분이 omp를 사용하고, 문제를 제보하고, 부족한 점을 제안하며 omp를 지금의 모습으로 만들어 주셨습니다. 이 글과 omp² 자체가 여러분 덕분에 존재합니다.
omp²를 접한 많은 분들이 가장 먼저 "그런데 왜요?"라고 물어보셨습니다.
fetch를 while 루프로 감싸는 건 단순해 보이지만, OpenCode, Pi, OpenClaw, omp가 동시에 전면 리팩터링을 진행하고 있는 데는 이유가 있습니다. 이 계열의 소프트웨어는 전례 없는 영역이었고, 단순한 버전부터 시작해봐야 비로소 어디가 문제인지 보이기 때문입니다.
피할 수 없는 복잡성에는 책임질 주체가 필요합니다. 현재 복잡성 보존 법칙의 무게는 확장 기능 개발자와 사용자 쪽으로 기울어져 있어, omp나 Pi 위에서 안정적인 소프트웨어를 만드는 것이 불가능합니다. 벌써 "뭐라고요, 확장하기 엄청 간단하고 편리한데요."라는 말이 들리는 것 같습니다. 몇 챕터만 읽어보시면 생각이 바뀔 겁니다.
다익스트라는 "단순함은 신뢰성의 전제 조건이다"라고 썼지만, 정작 그는 경로 탐색을 알고리즘으로 풀어낸 것으로 유명합니다. 왜 브루트 포스를 쓰지 않았을까요? 그는 단순함이 좋고 복잡함은 나쁘다는 simple good, complex bad 같은 주장을 전혀 한 게 아니었습니다. 그 조언은 구현자가 올바르게 추론하도록 돕기 위한 것이었습니다. 그런데 우리는 부끄럽게도 이를 구현자가 추론을 피하는 핑계로 쓰고 있습니다.
아우스터하우트는 스탠퍼드 강의 노트에서 빠진 절반을 채워줍니다. 그는 모듈 개발자에게 "고통을 껴안으라"고 말합니다. 어려운 문제를 떠안고, 완전히 해결하고, 그 결과를 다른 모든 사람이 쉽게 쓸 수 있게 만들라는 것입니다. 복잡성을 모듈 안으로 밀어 넣으세요. 모든 호출자가 조금씩 다른 복잡성의 복사본을 짊어지는 대신, 소수의 구현자가 전체를 감당하게 하세요.
Claude Code를 게임 엔진에 비유한 트윗에서 비롯된 밈 열풍을 기억하는 분들이 많을 겁니다. 얼핏 억지스러운 비교처럼 보이지만, 렌더링을 제외하고 하네스의 책임 목록을 나열해보면 꽤 잘 들어맞습니다.
하네스는 권위 있는 세계를 유지하고, 변경 사항을 저널에 기록하고, 신뢰할 수 없는 동작을 실행하고, 상태를 여러 뷰에 복제하고, 액터를 스케줄링하고, 명령을 해석하고, 호환되지 않는 프로토콜을 조율하며, 실시간 인터페이스를 렌더링합니다.
낯익게 들리지 않나요? 게임 엔진은 수십 년에 걸쳐 바로 이 복잡성의 범주들을 책임져 왔습니다.
이어지는 내용은 사후 분석인 동시에 플레이북입니다.
운영 모드를 먼저 정하라. 모든 서브시스템은 그 모드 전부를 지탱할 수 있어야 한다.
에이전트 하네스의 각 서브시스템을 논하기 전에, 성격이 전혀 다른 네 가지 제품이 이 하네스에 의존한다고 상상해 보세요.
이것들은 시장 페르소나가 아닙니다. 아키텍처 테스트입니다. 이 네 가지를 합치면, 하네스가 단순한 채팅 루프를 벗어나게 만드는 차원들이 모두 포함됩니다.
| 테스트 | 로컬/원격 | 대화형/자율형 | 신뢰 경계 | 동시성 |
|---|---|---|---|---|
| 멀티플렉스 워크스페이스 | 로컬 | 대화형 | 대부분 신뢰됨 | 다수의 에이전트, 하나의 워크스페이스 |
| 원격 드라이버 | 원격 | 대화형 | 호스트/클라이언트 분리 | 에이전트 하나 또는 다수 |
| 관전자 | 원격 뷰 | 관찰 전용 | 신뢰할 수 없는 프레젠테이션 입력 | 다수의 시청자 |
| 팩토리오 | 원격 또는 플릿 | 자율형 | 악의적인 저장소 및 도구 입력 | 다수의 작업 |
첫 번째 경우에만 동작하는 설계는 컨트롤러를 TUI에 끼워 넣고, 클로저에 상태를 두고, 확장 기능을 엔진 프로세스에서 실행하고, 무제한 호출에서 사람이 복구할 수 있다고 가정하게 됩니다. 네 가지 모두를 견디는 설계는 더 나은 경계를 갖추지 않을 수 없습니다.
이후 내용은 다섯 가지 귀결을 따라 전개됩니다.
이 제약들은 이후 모든 내용을 연결하는 결합 조직입니다. 이후 챕터에서 DOM, convar, Director, 소형 VM 스텁, 컴포넌트 렌더러를 제안할 때마다, 그것은 이 다섯 가지 요구사항 중 하나를 해결하는 것이지 멋있어 보이려고 서브시스템을 추가하는 것이 아닙니다.
첫 번째 요구사항이 토대입니다. 코드가 어디서 실행되는지, 어떻게 렌더링되는지를 결정하기 전에, 하네스는 무엇이 참인지를 알아야 합니다.
권위 있는 상태를 저널에서 도출할 수 없다면, 되감기·분기·재개는 모두 거짓말이다.
어떤 것을 영속적이고, 되감기 가능하고, 크래시에 강하고, 분기 가능하게 만들려면 세 가지 선택지가 있습니다.

Source Engine은 네트워킹에 두 번째 옵션의 변형을 씁니다. omp와 Pi는 현재... 이 중 어느 것도 일관되게 사용하지 않습니다. 이벤트는 존재하지만, 상태가 그 이벤트에서 실제로 파생되지는 않아 이벤트 소싱의 첫 번째 원칙인 상태는 이벤트만으로 도출 가능해야 한다를 위반하고 있습니다.

replay(.dem) == original. Pi의 저널은 메시지 트리만 커버하고, 권위 있는 상태는 그 밖에 따로 존재합니다. 되감기·분기·재개 모두 거짓말입니다.이렇게 된 데는 이해할 만한 이유가 있었습니다. 시스템 프롬프트와 AGENTS.md를 로그마다 반복하는 건 낭비이고, 이는 템플릿을 해시로 저장하고 변수를 따로 보관하면 해결됩니다. 또한 이런 상태 모델링 방식은 런타임 타입이 없는 TypeScript에서 흔하지 않습니다.
그러나 결과는 여전히 두 개의 진실 공급원으로 귀결됩니다.
| Source Engine | Pi 방식 하네스 | |
|---|---|---|
| 진실 공급원 | entity list, 그게 전부입니다. 서버가 시뮬레이션하고, 클라이언트가 예측합니다. | 메시지 트리 더하기 todo 상태, 재시도 카운터, 서브에이전트 레지스트리, 스트리밍 플래그 등 영속성에 보이지 않는 상태 |
| Δ의 단위 | { Δ entity ... }, 모든 델타가 엔티티 델타이므로 모든 필드를 커버함 | message / custom / custom_message, 엔진 소유의 폴드 없음; 확장 기능마다 직접 파생을 구현 |
| 전역 변수 | CCSGameRules은 싱글톤 엔티티입니다. 특별한 경우는 없습니다. | 세 계층 중 하나만 동작함 |
| 플러그인 상태 | 플러그인이 엔티티 필드에 쓰므로, 상태가 기본적으로 네트워크로 전달되고 재생됨 | 모듈 레벨 클로저: let turnCount = 0, new Map(), new Set() |
| 재생 | .dem를 로드하고, 특정 틱으로 이동하고, 다시 파생함 | .jsonl을 로드하면 리프 포인터가 이동하고, 나머지 권위는 제각각 리셋되거나 살아남음 |
전역 변수 행이 재미있는 부분입니다. Source에는 세션 전역 변수가 없고, 그냥 엔티티의 속성일 뿐입니다. 우리 것은 자체적인 계층 구조를 가지고 있습니다.

Source가 정확성을 얻은 건 정교한 조정자를 작성하거나 훌륭한 문서를 써서가 아닙니다. 재생 불가능한 상태를 표현 자체가 불가능하게 만들었기 때문입니다. 정확성은 바로 그 제약에서 나옵니다. 모든 확장 기능 개발자가 두 개의 훅을 등록하고 업데이트 형태를 정의해야 한다고 기억하는 것에서 오는 게 아닙니다.
공식 Pi 확장 예제 78개를 살펴봤습니다. 60개는 무상태였고, 상태가 있는 17개 중 올바른 것은 단 두 개였습니다.
| 예제 | 권위를 벗어난 상태 | 사용자가 겪는 문제 |
|---|---|---|
git-checkpoint.ts | 일시적인 Map가 소유한 체크포인트 참조 | /fork가 agent_settled이 이미 체크포인트를 지운 후에 실행됨 |
plan-mode/index.ts | 선택된 브랜치가 아닌 전체 파일에서 복원된 플랜 모드 | 되감기 후 제한이 활성 상태로 남음; 재개 시 죽은 브랜치가 되살아날 수 있음 |
status-line.ts | 클로저 안의 턴 카운트 | 3턴에서 1턴으로 되감으면 4턴이 생성됨; 재개 시 카운트가 0부터 시작됨 |
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가 사라집니다. 실시간 쓰기와 복원 읽기가 서로 다른 엔트리 타입을 씁니다.세션 전체가 하나의 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입니다. 현재 구체화된 상태와 목표 상태를 diff합니다. <subagent> 엘리먼트가 사라졌나요? 해당 엘리먼트를 삭제해 종료합니다. 나타났나요? 엘리먼트를 생성해 재개하거나 스폰합니다. 델타 자체가 완전한 생명주기 작업 목록입니다.
상태를 가진 기능을 추가해도 되감기·분기·재개·복제의 호출 위치가 늘어나지 않습니다.
프롬프트는 프로젝션이 됩니다. 모든 템플릿에 100줄짜리 상태 객체를 넘길 필요가 없습니다. 시스템 프롬프트도 다른 모든 것과 같은 트리를 읽습니다.
- {{ count(select("todo item[status!=completed]")) }} open items복제는 구독이 됩니다. 애플리케이션과 파생 로직은 이미 있습니다. 원격 클라이언트는 파일을 테일링하는 대신 패치 스트림을 구독합니다. 원격 드라이버와 관전자 케이스에서 더 이상 별도의 상태 배관이 필요하지 않습니다.
렌더링은 프로젝션이 됩니다. 컴포넌트 레지스트리가 동일한 엘리먼트 상태에서 Read, Bash, 메시지, 서브에이전트를 렌더링할 수 있습니다. 스트리밍 인수는 <input>를 변경하고, 스트리밍 출력은 <result>을 변경합니다. 7챕터에서는 이를 별도의 맞춤 렌더러 대신 타입이 있는 인터페이스로 만듭니다.
이 분리 덕분에 서브에이전트도 검사 가능해집니다. Pi의 뷰는 세션 상태를 직접 읽는데, 예를 들어 푸터가 sessionManager.getEntries()를 호출합니다. 그래서 "서브에이전트 검사"를 추가하려면 컨트롤러 상태를 UI 내부 구석구석에 연결해야 합니다.
컨트롤러와 액터를 완전히 분리하세요. 컨트롤러는 세션 상태를 소유하고, 액터는 그 스냅샷과 패치 스트림만 렌더링합니다. TUI, 원격 클라이언트, 서브에이전트 인스펙터는 동등한 피어가 됩니다. 자식을 검사한다는 건 같은 액터를 자식의 상태로 향하게 하는 것입니다.
정직한 상태 모델이 토대이지만, 신뢰할 수 없는 코드가 상태를 변경하는 정책을 소유하면 여전히 흔들릴 수 있습니다. 다음 챕터는 런타임 경계를 그립니다.
정책은 신뢰할 수 있는 호스트에 두고, 샌드박스 안에는 대역폭이 제한된 실행 스텁만 넣어라.
상태 챕터는 하네스가 무엇을 믿는지를 다뤘습니다. 런타임 챕터는 누가 그것을 변경할 수 있는지, 신뢰할 수 없는 작업이 어디서 실행되는지, 그리고 실행이 몇 시간 동안 지속되거나 출력을 스트리밍하거나 정중한 종료 요청을 무시할 수 있게 된 지금 "도구 호출"이 무엇을 의미하는지를 결정합니다.
설계 범위의 팩토리오 케이스에서 시작해봅시다. roboomp를 클론해서 gpt spark에게 이름을 전부 CodeWhatever로 바꾸게 하고, 이 마법 같은 기법으로 사람들에게 수천 달러를 받는다고 가정합시다. 도구는 누가 실행할까요? VM이죠, 당연히. 그렇겠죠.
실행자를 VM 안에 넣으면 어떤 일이 벌어지는지 살펴보겠습니다.

음, 이건 안 됩니다. 왜냐하면:
그렇다면 구동 앱을 VM 안에 넣어 봅시다!

해결책은 VM 안에 순종적인 스텁 하나만 넣고, 스트리밍되는 최대 데이터량을 엄격하게 제한하는 것입니다 (잘못 사용된 Read 도구에서 2GB 응답이 오면 안 됩니다):

다이어그램들은 하나의 경계로 귀결됩니다.
이 구조는 팩토리오 케이스를 충족하면서도 로컬 사용을 악화시키지 않습니다. 같은 호스트가 스텁을 로컬 프로세스, 컨테이너, VM, 원격 머신 중 어느 것으로도 향하게 할 수 있습니다.
경계는 호스트 대 VM만의 문제가 아닙니다. 서브에이전트는 파일시스템 계층에도 같은 경계가 필요합니다. 워크트리는 추적된 파일만 격리하는 반면, pi-iso은 APFS, btrfs, ZFS, overlayfs, ProjFS, 또는 복사 폴백을 사용해 각 자식에게 전체 워크스페이스의 copy-on-write 뷰를 제공합니다. 자식은 분기하고, 부모는 diff를 받습니다.
자식은 뷰를 받고 변경 사항을 돌려보냅니다. 부모의 변경 가능한 권한을 공유하지 않습니다. 이것이 호스트/샌드박스 규칙의 파일시스템 형태입니다.
그렇다면 도구는 어떻게 정의할까요? 나중에 초기 변경 사항을 살펴보겠지만, 핵심 계약은 대부분 그대로 유지했습니다.
export const myCustomTool: ToolDefinition = {
name: "my_tool",
parameters: mySchema,
// 1. Called during argument streaming & before execute()
renderCall(args, theme, context) {
if (context.argsComplete) {
// Trigger async preview computation
}
return new Text("Pre-execution preview UI...", 0, 0);
},
// 2. Main execution
async execute(_id, params) {
/* ... -> string */
},
// 3. Called after execute() settles
renderResult(result, options, theme, context) {
return new Text("Final execution result UI", 0, 0);
},
};이 계약은 보기엔 작고 단순하지만, 하나의 작업을 세 개의 무관한 단계로 쪼갭니다. 미리보기, 실행, 모델 결과, 사람 결과, 진단, 스트리밍 업데이트, 취소, 저널 레코드가 모두 같은 호출을 설명하는데, API는 이들이 별개인 척 만들고 있습니다.
첫째로, 렌더러 경로를 분리하면 반응성이 선택 사항이 됩니다. 렌더링된 도구가 새로운 형태로 "스냅"되지 않더라도, 개발자는 프레젠테이션 로직의 상당 부분을 중복 구현해야 합니다.
더 큰 문제는 execute의 동작 방식입니다. Edit을 예로 들어봅시다.
renderCall은 파일을 열고, (읽은 부분을 어딘가에 캐시하길 바라며) 편집을 적용하고, diff를 렌더링합니다.execute이 파일을 다시 열고, 전부 적용하고, 작성한 뒤 모델 친화적 형식으로 diff를 반환합니다.renderResult이 이 diff를 받지만, 우리가 정한 형식을 파싱해야 합니다! 왜냐고요? 물론 사람은 색상이 있고 강조된 버전을 보고 싶을 테니까요. 좀 더 나은 줄 번호와 함께요.이런 구조는 자연스럽게 다음과 같은 구현으로 이어졌습니다.
효율적으로 만들려면 이 정의 밖에서 직접 구동하는 코루틴을 구현하고, 그 핸들을 저장할 장소를 찾아야 하며, 그래도 여전히 결과 역직렬화 작업 전체를 구현해야 합니다.
문제는 단순한 코드 중복이 아닙니다. 계약에 "인수 스트리밍 중" → "실행 중" → "완료"로 상태가 전이하는 권위 있는 객체가 없습니다. 모든 구현이 그 생명주기를 위한 사이드 채널을 직접 만들어냅니다.
도구 실행은 텍스트를 반환하는 비동기 함수가 아니라, 제한되고 취소 가능한 상태 스트림입니다.
구조화된 경고, 진단, 또는 잘림 알림을 추가하는 일반적인 방법도 없습니다. 대부분의 Pi 도구 구현은 결국 이런 식으로 처리합니다.
text += `\n${theme.fg("warning", `[Truncated: ${truncation.outputLines} lines shown (${formatSize(truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit)]`)}`;그러면 모델은 도구 데이터가 어디서 끝나고 하네스 주석이 어디서 시작하는지 추측해야 합니다. execute이 제너레이터가 아니기 때문에, 스트리밍 출력을 위해선 업데이트 채널 위에 또 다른 프로토콜이 필요합니다.
DOM 모델은 두 가지 특별한 경우를 모두 제거합니다.
<result> 바디를 변경하고,<diag severity="warn">이 생성됩니다.실행이 진행되는 동안 클라이언트는 이 상태에 대한 패치를 받습니다. 완료되면 이전 상태 대비 최종 diff가 저널에 기록됩니다.
통합 세션 모델에서 호출은 구조화된 자식 엘리먼트를 가진 엘리먼트입니다.
<Edit id="e41" status="running" version="3">
<input i="Update the parser without changing the public API">…</input>
<result>…streaming structured state…</result>
<diag severity="warn">…</diag>
<usage tokens="0" elapsed-ms="842"/>
</Edit>실행자는 실행 중에 이 엘리먼트를 변경합니다. 모델, 사용자, 저널, 원격 클라이언트, 테스트 하네스는 동일한 상태의 서로 다른 프로젝션을 관찰합니다. 완료되면 최종 diff가 고정되고, 어떤 클라이언트도 직렬화 이전에 존재했던 더 풍부한 객체를 복구하기 위해 결과 문자열을 파싱할 필요가 없습니다.
Pi 도구에는 한도가 없습니다. 1MB의 텍스트를 반환하면 그대로 모델에 전달됩니다. 이는 너무 저수준의 기본 요소입니다.
Pi도 Bash과 Read에서 이 문제를 직접 겪었고, 구현체들이 공유할 수 있는 잘림 유틸리티를 내보내는 방식으로 해결했습니다. omp는 모델이 보존된 전체 출력을 읽어올 수 있도록 아티팩트 시스템으로 이 유틸리티를 확장했지만, 책임은 Pi가 둔 곳에 그대로 남겨뒀습니다. 각 구현체에요.
1MB를 모델에 보내는 것 자체는 유지할 가치가 있는 기능일 수 있지만, 그건 명시적인 notrunc 속성으로 선택 해제하는 방식이어야 합니다. 잘림이 좋은 설계에 선택적으로 참여하는 방식이 아니라요. 헬퍼를 선택 사항으로 남겨두면 두 가지 방식으로 실패합니다.
대부분의 도구는 어느 정도 잘림이 필요하므로, 선택적 헬퍼는 불균등한 적용을 보장합니다.
대화 렌더링 계층이 아닌 도구 구현 내부에서 잘림이 일어나면 코드 모드가 망가집니다.
Eval 안에서 도구 출력을 신뢰할 수 없습니다. 모든 사용에서 먼저 데이터에서 하네스 알림을 파싱해 내야 합니다.Eval 결과 자체도 잘릴 수 있어서, 각 호출은 동일한 데이터에 N+1개의 독립적인 잘림 계층을 쌓습니다.무엇이든을 백그라운드로 처리하고, 호출이 블로킹할 수 있는 시간을 제한하는 것 역시 라이브러리 계층의 책임입니다. 오래 실행되는 도구 각각의 책임이 아닙니다.
첫 번째 이유는 캐싱과 UX입니다. 예상치 못하게 오래 걸리는 호출은 에이전트가 알아차리고 조정할 수 없게 만들고, 사용자는 멈춘 세션으로 돌아오고, 자율 작업은 영원히 기다리며, 호출이 반환되기 전에 프로바이더의 KV 캐시가 만료됩니다.
두 번째 이유는 omp도 잘못 처리한 중복입니다. 모든 도구가 자체 백그라운드 처리를 갖추면, 모든 도구가 자체 스폰, 폴, 메시지, 종료, 목록 조회 헬퍼도 갖추게 됩니다. Claude가 자체 Task과 Bash 도구를 중심으로 그린 다이어그램을 보세요.

둘 다 프로세스의 인터페이스로 수렴합니다: signal + stream in + stream out. 백그라운드 셸, 서브에이전트, 개발 서버 데몬, 원격 함수, 예산을 초과한 일반 호출은 모두 같은 객체입니다. stdin, stdout, 종료 상태, 시그널 핸들을 가진 작업(job)이요. 표준 입출력 형태의 작업 기본 요소 하나가 이 모두를 캡슐화해야 합니다. 그러면 블로킹 예산이 한 곳에서 적용되고, 출력이 하나의 아티팩트 경로로 넘치며, 어떤 것이든 검사·메시지 전송·종료하는 것이 도구마다 복사된 구현이 아닌 하나의 인터페이스가 됩니다.
가시성 기대도 같은 방식으로 수렴합니다. 서브에이전트 상태를 보고 싶은 사용자는 백그라운드 셸도 보고 싶어 합니다. 하네스 인스턴스 간 피어에게 메시지를 보내는 에이전트는 해당 피어가 실행하는 데몬도 보고 싶어하므로, 같은 디렉터리의 N개 에이전트가 N개의 포트에서 N개를 실행하는 대신 하나의 HMR bun dev을 공유할 수 있습니다.
확장 기능, 그리고 따라서 커스텀 도구가 엔진의 JavaScript 격리 공간을 공유하면 재앙이 됩니다. 제대로 된 핫 리로드가 사실상 불가능해지고, 한번 협력적 취소를 벗어난 도구 호출은 강제로 멈출 수 없습니다.
JavaScript와 Go는 AbortSignal와 context.Context을 통해 취소를 노출합니다. 유용한 프로토콜이지만 강제되지는 않습니다. 시그널 전달을 잊거나, 시그널을 받지 않는 의존성을 호출하거나, 동기 작업을 실행하거나, 무한 재시도 루프에 들어가면 타임아웃은 에이전트에게 계속하라고 말할 뿐이고, 작업 자체는 백그라운드에서 리소스를 계속 소모할 수 있습니다.
안전한 호스트에는 실제로 종료할 수 있는 실행 단위, 즉 죽어도 세션 권한을 함께 가져가지 않는 프로세스, 워커, 서브인터프리터, VM 요청 또는 이에 준하는 경계가 필요합니다. 취소는 모든 도구 개발자의 선한 의도가 아니라 런타임 계약의 일부여야 합니다.
의도적으로 단순한 샌드박스 스텁은 SDK 문제를 하나 더 만들어냅니다. 확장 기능 개발자가 이제 두 개의 파일시스템을 보게 됩니다. 커스텀 편집 함수는 한쪽에서 파일을 읽고, 통째로 전송한 뒤, 다른 쪽에서 다시 써야 할 수도 있습니다.
그래서 omp²는 확장 기능에 Python을 선택했습니다. Python은 표준 라이브러리로 자체 AST를 검사하고, 함수에 필요한 소스를 패키징해 다른 런타임에 제출할 수 있습니다. @remote 어트리뷰트 하나로 로컬처럼 보이는 함수를 RPC로 만들 수 있습니다. Modal의 Python SDK 같은 시스템에서 원격 함수가 자연스럽게 느껴지는 것과 같은 특성입니다.
Python 런타임을 함께 도입하면 Eval도 어떤 인터프리터가 설치되어 있는지에 따라 달라지는 것이 아니라 안정적으로 사용할 수 있게 됩니다. 일석이조입니다.
작업에 신뢰할 수 있는 소유자와 취소 가능한 실행 기본 요소가 생기면, 하네스에는 아직 값과 멀티턴 동작을 일관되게 제어하는 방법이 필요합니다. 그것이 컨트롤 플레인입니다.
런타임은 두 가지 다른 종류의 제어를 담당합니다. 값은 어떤 모델, 티어, 테마, 정책이 활성 상태인지를 결정합니다. 동작은 에이전트가 양보할 수 있는지, 반드시 한 번 더 턴을 가져야 하는지, 임시로 특정 기능이 필요한지를 결정합니다. 두 가지 모두 모든 호출자가 자체 세터나 플래그를 소유하면 일관성이 깨집니다.
범위, 영속성, 상속, 복제는 세터 호출 위치가 아닌 각 설정의 선언부에 속한다.
설정 시스템도 지뢰밭이 됐습니다. 더티 트래킹과 여러 단계의 설정 계층(전역, 세션 레벨, 임시 등)이 뒤엉켰습니다. Pi에서와 마찬가지로 대부분의 get/set 연산이 AgentSession 타입을 통해 라우팅됐는데, 변경 사항을 JSONL에 영속화해야 했기 때문입니다.
이 모든 문제를 이미 오래전에 해결한 설정 시스템이 뭔지 아세요? 바로 Source Engine입니다!
특히 놀라운 점은, Valve 게임을 한 번이라도 접해본 사람이라면 sv_cheats이 뭘 하는지 바로 떠올린다는 것입니다. 수년에 걸쳐 수많은 사람들이 자신만의 설정을 커스터마이징했는데도, 불만족스러운 사용자가 단 한 명도 기억나지 않습니다. 다른 소프트웨어의 다른 설정 시스템에서 이런 경험을 해보신 적 있나요?
convar는 이름, 기본값, 도움말 문자열, 그리고 플래그의 비트필드를 가진 타입이 있는 변수로, 정의된 위치에서 한 번만 선언합니다.
ConVar sv_gravity("sv_gravity", "800", FCVAR_REPLICATED | FCVAR_NOTIFY, "World gravity.");영속성, 소유권, 범위, 복제, 심지어 재생 정확성까지, 모두 변수가 태어나는 곳에 명시된 변수의 속성입니다. 아무도 set를 갓 오브젝트를 통해 라우팅하지 않고, 더티 트래킹을 직접 구현하지 않습니다.

sv_cheats 뒤에 변수를 잠그고, ARCHIVE는 config.cfg에 도달하는 것을 결정합니다. 그리고 모든 변경 사항은 .dem에 기록됩니다.convar는 세션 DOM 옆에 있는 두 번째 설정 데이터베이스가 아닙니다. 세션 범위의 convar는 권위 있는 트리 안에 있는 하나의 저널링된 노드이며, 플래그가 재개·되감기·스폰·복제·보관에 어떻게 참여하는지를 선언합니다.
현재 omp에서 서비스 티어(즉, /fast)는 서브에이전트를 위한 별도 설정을 따로 갖고 있습니다.
tier:
openai: priority
subagent: inherit # separate settingconvar 방식에서 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도 콘솔 명령어이므로, 우리가 계속 발명하는 모든 입력 패턴의 스키마가 인밴드로 유지됩니다. 사용자가 생각 과정 숨기기에 키바인드를 원한다면?
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우리의 키바인딩 레이어가 이래야 합니다. 자체 기본값 테이블을 가진 맞춤 스키마가 아니라요!
명령 스트림이 결합 조직입니다. cfg 파일, 콘솔 입력, 별칭, 바인드, 원격 관리, 저널 재생이 모두 같은 선언된 변수 위에서 같은 언어를 씁니다. 커스터마이징이 일회성 스키마를 계속 양산하는 것을 멈춥니다.
턴에 걸쳐 제어를 유지할 수 있는 모든 것은 에이전트가 소유하는 하나의 조합 가능한 Director 기본 요소에 속한다.
또 다른 관심사는 확장성입니다. Pi는 실제로 훌륭한 확장 레이어를 갖추고 있지만, "루프" 모양의 구멍이 있습니다.
Pi에서 가장 인기 있는 Plan과 Goal 구현을 설치해봤습니다. 둘 다 활성화하면 이렇게 됩니다.

그렇군요! 흥미롭지만 "워크플로우" 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);
});
}아, 두 구현 모두 같은 개발자가 만들었고, 그 개발자가 이 문제를 직면하고 자신의 플러그인 suite에서 동작하는 해결책을 구축한 것입니다.
이 동작을 캡슐화하는 시스템을 도입하는 복잡성이 플러그인 개발자에게 전가됐고, 그들은 자신의 확장 기능들 사이에서만 동작하는 시스템을 만들 수 있을 뿐입니다.
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—더 많은 내장 동작을 공개 확장 인터페이스 위로 올려서 그 인터페이스의 구멍이 외면할 수 없게 만드는 것입니다.
에이전트에는 루프가 있습니다. 점점 더 많은 것들이 그 루프를 지휘하려 합니다. plan은 플랜 파일이 생길 때까지 한 번 더 턴을 원하고, goal은 목표가 완수될 때까지 한 번 더 턴을 원하고, /force은 다음 추론을 변경하려 하고, todo 리마인더는 양보 전에 마지막으로 이의를 제기할 기회를 원합니다.
그러니 에이전트 레이어에 그 결정을 소유하는 단 하나의 객체, Director 스택을 주세요.
"스택"이란 나중에 직렬화하겠다고 약속하는 Python 배열이 아니라, 세션 DOM 안의 살아있는 서브트리를 의미합니다. 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(): returnprepare_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이 활성 상태인 동안, Plan은 계속하거나, 자식을 푸시하거나, 사용자에게 직접 양보합니다. 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을 푸시하고, 자식이 완료되면 같은 양보 후보를 돌려받아 계속하거나 사용자에게 반환합니다.
이것으로 plan, goal, vibe, autoresearch, 리마인더, 외부 검증 동작이 서로의 비공개 플래그를 알 필요 없이 같은 에이전트 레이어 기본 요소를 사용할 수 있습니다.
ForceTool는 의미론적 요청을 표현합니다: "다음 성공적인 턴은 반드시 write를 호출해야 한다." 선택된 프로바이더가 네이티브 tool_choice을 지원하는지, 강제 적용이 캐시를 파괴하는지, 로컬 모델이 추가 프롬프트가 필요한지는 알지 못합니다. 그 번역은 추론 레이어의 몫입니다.
컨트롤 플레인은 이제 무엇이 일어나야 하는지를 말할 수 있습니다. 다음 챕터는 그 요청이 호환되지 않는 모델과 프로바이더 전반에서 같은 의미를 갖도록 만듭니다.
모델 호환성은 코드 곳곳에 흩어진 프로바이더 이름 분기가 아니라, 명시적 우선순위를 가진 구조화된 지식이다.
컨트롤 플레인은 의미론적 동작을 요청합니다: 이 모델로 스트리밍하고, 저 기능을 강제하고, 이 형태를 적용하고, 이 토큰을 카운트하세요. 추론 레이어는 그 요청을 이 정확한 모델이, 이 정확한 호스트에서, 이 정확한 API를 통해 실제로 할 수 있는 것으로 번역해야 합니다.
이건 이미 omp v1에 before/after 커밋이 있어서 설명하기 쉽습니다.
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를 통해서인지에 따라 다릅니다. 이 루프백 URL이 llama.cpp인가요, LiteLLM이 다른 걸 프록시하는 건가요? 또 하나의 예외를 추가하는 게 낫겠네요.
개별 분기 자체는 아무 문제 없습니다! 각각이 실제 프로바이더 버그를 수정했습니다. 문제는 같은 지식이 여러 곳에 인코딩됐다는 것입니다.
compat/openai.ts: 880줄model-thinking.ts: 977줄variant-collapse.ts: 1,776줄무엇이 이를 대체했을까요?
taxonomy/ "what model is this string?"
classes/ "what is true of this model lineage?"
providers/ "what does this host change?"Anthropic 씽킹은 이제 이렇게 읽힙니다.
class "anthropic" {
on "anthropic" "amazon-bedrock" "google-vertex" {
family "sonnet" {
revision ">=3.7 <4.6" { thinking-mode "budget" }
}
revision ">=4.7" {
thinking-mode "anthropic-adaptive"
}
}
}이것이 우리가 표현하려던 실제 지식입니다! 4.6 이전 Sonnet 버전은 budget thinking을 쓰고, Anthropic 4.7+은 adaptive thinking을 씁니다. 그리고 확인한 호스트에서만 이를 선언합니다.
KDL 자체가 마법은 아닙니다. 더 예쁜 형식으로 같은 혼란을 재현하는 걸 막아준 건 컴파일러입니다.
이게 프로바이더를 덜 이상하게 만들었냐고요? 물론 아닙니다. requires-mistral-tool-ids, qwen-preserve-thinking, strip-deepseek-special-tokens 같은 이름의 호환성 축과 "추론 끄기"를 표현하는 열 가지 방법은 여전합니다. 그 이름들을 보고 눈물을 흘리세요.
이것이 우리를 구한 건 다음 특이 사항을 네 개의 다른 함수에서 또 다른 분기로 표현하는 일에서입니다. 이제 각 사실을 소유하는 곳에 규칙 하나가 있고, 우선순위가 모호하면 컴파일러가 소리를 지르며, 추론 레이어가 마침내 답할 수 있습니다: 이 정확한 모델이, 이 정확한 호스트에서, 실제로 무엇을 지원하는가?
얻은 것은 특이 사항의 감소가 아닙니다. 각 사실의 단일 소유자, 명시적 우선순위, 그리고 라이브러리가 아직 답을 확립하지 못했을 때의 unknown 상태입니다. 하네스의 나머지 부분이 프로바이더 이름 분기를 통해 모델 정체성을 다시 발견하는 일을 멈춥니다.
stream 이상이다웹 검색용 Pi 플러그인을 구현하는 순간 이 문제가 돌아와 저를 괴롭힐 것은 거의 예정된 일이었습니다. 실제로 같은 압박이 저장소의 미니멀리스트적 기원에도 적용됐는데, Pi의 새로운 이미지 모델 구현에서 확인할 수 있습니다.
Pi는 프로바이더를 stream와 streamSimple으로 모델링하는데, 그게 전부입니다! 프로바이더를 빠르게 구축하기에는 좋지만, 그 위에 점점 더 많은 것을 쌓기에는 좋지 않습니다. 왜냐하면:
이 중 하나를 구현하는 모든 확장 기능이 동기화된 OAuth 갱신과 재시도도 올바르게 구현하리라 생각하시나요?
그 외에도, 추론 프로바이더가 지원하는 최신 제어 기능에 접근할 수 있는 것은 큰 이점입니다. 몇 가지 예를 들면:
인증 갱신, 재시도, 토큰 카운팅, 검색, 생성, 검색(discovery), 프로바이더 네이티브 제어는 공유 인프라입니다. 이를 확장 기능에 맡기면 동일한 프로토콜의 여러 불완전한 구현이 난립하게 됩니다.
강제 도구 호출은 "플래그를 지원한다"는 것이 왜 충분하지 않은지를 보여줍니다.
이상적인 하네스 구현은:

이것이 앞 장에서 소개한 Director의 프로바이더 측 구현입니다. ForceTool은 불변 조건을 명시하고, 추론 레이어는 이를 만족하는 가장 저렴하고 정직한 방법을 선택하며 모델이 따르지 않으면 에스컬레이션합니다.
도구의 parameters 필드는 인수 형태를 엄격하게 정의합니다. 사람을 위한 API라면 이상적이겠지만, 모델은 범용 API 클라이언트가 아닙니다. 모델의 실수는 도구 이름이나 훈련에 포함된 하네스의 표현 방식에 따라 달라지는 경우가 많습니다.
RL로 최적화된 에이전트는 다른 하네스의 스키마를 사용해 익숙한 도구를 호출하기도 합니다. 컴포저 모델은 Grep 도구가 존재하지 않아도 자신이 기대하는 형태로 Grep을 내보내기도 합니다. Codex는 paths: string[]를 보고 그날의 기분에 따라 ;이나 ,으로 구분된 단일 문자열을 보낼 수 있습니다.
따라서 라이브러리는 검증과 보정을 함께 수행해야 합니다. 도구의 의미적 계약에는 엄격하되, 모델의 방언(dialect)에는 너그럽게 대응하세요. 매핑이 모호하지 않다면 paths: "a,b"을 리스트로 수정하고, 그렇지 않다면 구조화된 재시도 가능 오류를 반환하세요. 원시 JSON 스키마 검증기만으로는 이 레이어를 온전히 담당할 수 없습니다.
제약된 샘플링은 Pi에 가장 먼저 추가한 기능 중 하나였습니다.
+ strict?: boolean;
+ customFormat?: { syntax: "lark" | "regex"; definition: string };
+ customWireName?: string;이후 Pi는 LARK와 strict 지원을 추가했지만, 프로바이더 레이어가 그대로 통과시키는 불투명한 구조체로 노출했습니다. 시스템 전반의 두 가지 제약이 이를 불충분하게 만듭니다.
이것이 바로 겉보기에 "복잡한" 구현이 추론 레이어에 귀속되어야 하는 이유입니다.

strict을 제대로 지원하려면 프로바이더 capability 확인, 우선순위가 있는 strict 스키마 예산, 방언별 정규화, 그리고 클라이언트 측 복구 경로가 필요합니다. 불투명한 pass-through 구조체로는 이 중 어느 것도 제공할 수 없습니다.확장 기능은 의도를 선언합니다(엄격성, 문법, 우선순위). 추론 레이어는 capability, 예산, 방언 정규화, 폴백, 복구, 최종 전송 포맷을 담당합니다.
추론 라이브러리는 다음도 처리해야 합니다.
tool_call 및 think 블록으로 합성
이 주제의 도구 호출 측면에 대해서는 이전 포스트에서 다룬 바 있습니다. 프로바이더나 모델을 지원하려면 URL 연결뿐 아니라 각각의 개별적인 특이 사항도 처리해야 합니다.
프로바이더 어댑터는 스트림을 열 수 있을 때 완성되는 게 아닙니다. 잘못된 형식의 JSON, 반복, 누출된 추론, 모델별 도구 호출 방언에도 불구하고 하네스의 나머지 부분이 정식 단일 턴을 받을 수 있을 때 비로소 완성됩니다.
저널 스냅샷을 기반으로 한도에 도달하기 전에 요약을 시작하고, 한도에 도달했을 때 해당 스냅샷이 여전히 해당 브랜치를 설명하는 경우에만 커밋하세요.
단순한 설계는 최악의 사용자 경험을 제공하기도 합니다. 사용자가 가장 몰입해 있는 바로 그 순간, 세션에서 가장 큰 요청을 기다리게 됩니다.
Snapcompact 같은 방법을 활용하더라도, 여기에는 여전히 개선할 여지가 많습니다.

대신 해야 할 일은, 한도에 도달하기 약 10% 전에 컴팩션 프로세스를 투기적으로 시작하는 것입니다. 대화를 두 개의 동시 버전으로 분기시키는 것과 같습니다. 하나는 사용자와 모델이 계속 작업하는 버전이고, 다른 하나는 모델이 대화를 컴팩션하는 버전입니다.

응답을 받으면 다른 브랜치에 접합(splice)하세요. 이렇게 하면 작업의 흐름도 유지됩니다. 모델은 히스토리에 핸드오프 메시지만 남아 있어 혼란스러워하는 대신, 어차피 진행했어야 할 모든 진행 상황을 그대로 볼 수 있게 됩니다.
프롬프트 외에도 고려할 만한 방법들이 있습니다.
이 부분은 UI 렌더링과 요청 렌더링 추상화를 설계할 때도 고려해야 합니다. 사용자는 히스토리를 볼 때 모든 메시지가 그대로 유지되기를 기대하지만, 모델에게는 해당 메시지들이 존재하지 않습니다. 따라서 요청 fn(this, req) -> req을 생성할 때 프롬프트 히스토리의 각 항목을 "폴드(fold)"로 모델링하고, <Handoff> 구현 안에서 처리해야 합니다.
소형 로컬 모델은 매우 유용합니다! 프론티어 모델만 사용하더라도, 임베디드 tiny 모델(특히 LiquidAI 모델을 추천합니다) 구현을 권장합니다. 분류 작업이나 제목 생성, 번역, 대화 만족도 판단 같은 소규모 작업에서 지연 시간과 비용을 크게 절감할 수 있습니다. 물론 TTS/STT에도 활용할 수 있으며, 이미 로컬에서 최고 수준의 성능을 낼 수 있습니다.
이것은 두 번째 "에이전트"가 아닙니다. 프론티어급 지연 시간이나 비용을 지불할 필요 없는 소규모 작업을 위한 저렴한 내부 기능입니다.
호환성과 복구가 중앙화되면, 영구 도구 표면은 작게 유지될 수 있습니다. 다음 장에서는 어떤 것이 매 요청마다 스키마를 가질 자격이 있고, 어떤 것은 절대 그럴 필요가 없는지를 다룹니다.
모든 영구 도구는 매 턴마다 비용을 부과합니다. 목록을 작게 유지하고 각 기본 기능을 깊게 만드세요.
런타임 장은 작업 실행 방식을 정의했습니다. 추론 장은 스키마가 모델과 프로바이더를 넘어 살아남는 방법을 정의했습니다. 이제 제품 관점의 질문을 던질 수 있습니다. 어떤 연산이 모델의 영구 문법을 차지할 자격이 있을까요?
대부분의 도구를 모델에게 제시하는 가장 좋은 방법은 영구 도구 목록에 아예 포함시키지 않는 것입니다.
얼마 전, omp가 동일한 작업에서 Codex보다 느리다는 불만이 들어왔습니다. 토큰 기준이 아니라 벽시계 시간 기준으로요. 대수롭지 않은 문제라고 생각했는데, 놀랍게도 사실이었습니다. 거의 두 배 차이가 났습니다!

sol, 6회 실행 중간값, 매번 새 세션 · 청록색 = omp 변형, 회색 = 외부 기준 · 주석은 위 행 대비 델타.원인은 도구 목록이었습니다. 필수 도구 다섯 개로 제한하면 36.6s을 달성하며, Codex의 42.2s과 Pi의 37.0s을 앞섭니다. 이유가 무엇일까요? 바로 도구 문법입니다! 모델에게는 단순한 텍스트 설명처럼 보이지만, 대부분의 프론티어 모델 프로바이더에서 토큰 생성 과정에 실질적으로 영향을 미칩니다. 항상 유효한 JSON을 출력하도록 유도하며(설명에 사용된 토큰에 더해서), 이 자체가 토큰 생성을 추가로 소비합니다.
도구는 모델이 필요할 때를 대비해 넣어두는 공짜 기능이 아닙니다. 이것이 바로 동적 도구 디스커버리의 핵심 아이디어입니다. 다만 이 동적 방식은 도구 목록이 변경될 때마다 캐시가 무효화된다는 단점이 있어, 우리가 크게 선호하지 않는 이유이기도 합니다.
Pi가 한 가지는 제대로 파악했고, 우리도 항상 동의한 부분이 있습니다. MCP는 설계가 엉망이며 영구 도구 레이어에 속하지 않는다는 것입니다. 그렇다면 Figma MCP를 원하는 사용자의 요구와 추론 제약 조건을 어떻게 동시에 충족할 수 있을까요?
동적 도구 디스커버리는 영구 문법 비용을 피할 수 있지만, 목록이 바뀔 때마다 캐시가 무효화됩니다. 더 나은 목표는 안정적이고 작은 문법을 유지하되, 일반적인 조합을 통해 접근할 수 있는 긴 꼬리(long tail)를 두는 것입니다.
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 스키마에서 합성된 것으로, 훌륭한 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을 노출합니다. 하나의 안정적인 스키마 안에서 여러 연산을 조합할 수 있습니다. 연산 집합이 한정적이라면 스키마로, 개방형이라면 코드 표면으로.
두 가지 형태는 API의 성격에 따라 쓰임새가 다릅니다. 연산 집합이 한정적이라면 스키마로 남겨도 되고, 개방형이라면 하나의 호출 안에서 여러 연산을 조합하는 코드 또는 명령 표면이 적합합니다. 어느 쪽이든 디스커버리 이후에 영구 목록을 변경할 필요가 없습니다.
계약에서 하나만 꼽으라면 언급할 가치가 있는 변경이 있습니다. 모든 도구에 i intent 인수를 추가하는 것입니다. 인수 스트리밍 중에 전달되므로, renderCall는 호출이 완료되기 전에 모델이 무엇을 하려는지 표시할 수 있습니다. 모든 도구가 reason / purpose을 각자 구현하지 않아도 저널에 읽기 좋은 요약이 기록됩니다.
도구에는 버전을 부여해야 합니다.
트레이스 활용이 훨씬 쉬워집니다. 자주 변경되는 도구의 I/O를 파싱하고 어떤 계약이 각 호출을 만들었는지 추측하지 않아도 시간에 따른 성공률을 평가할 수 있습니다.
이름, 버전, 의도, 입력, 출력, 진단, 사용량은 모두 프로토콜 데이터입니다. 트레이스가 평가나 복구에 활용되는 순간, 이 중 어느 것이든 추측에 의존하는 것은 피할 수 있는 기술 부채가 됩니다.
작은 목록이 효과를 발휘하는 것은, 그 기본 기능들이 의미론적인 이유로 폭넓게 설계되었을 때입니다. 관련 없는 기능을 하나의 switch 문에 쑤셔 넣었기 때문이 아닙니다. omp의 빌트인이 좋은 예입니다.
omp에서 가장 단순해 보이는 이 도구는, 사실 다른 하네스의 도구 20개에 해당하는 기능을 담고 있습니다.
Ls이 따로 필요 없습니다.ReadNotebook 도구 없이도, .ipynb 파일을 읽으면 기본적으로 깔끔한 출력을 얻을 수 있습니다..pdf, .docx, .pptx, .xlsx, .epub? 마크다운으로 추출된 내용을 받습니다..cpuprofil, .sample.txt? 예상하셨겠지만, 병목 요약을 받습니다..sqlite, .sqlite3, .db, .db3? 테이블 목록 조회, 스키마 확인, 행 검색, 쿼리 실행 모두 가능합니다.:img을 추가하세요.http://...의 온라인 리소스에도 적용되며, 범위는 필요 시 읽습니다. 일반 웹 페이지는 web_fetch처럼 마크다운으로 변환됩니다.이것은 기발함을 위한 다형성이 아닙니다. 모델의 관점에서 이는 모두 동일한 연산입니다.
이 리소스를 내가 추론할 수 있는 가장 유용한 형태로 구체화하라.
코드의 경우, 대형 선언 본문을 줄임표로 대체하는 구조적 요약도 반환할 수 있습니다. 클래스 X를 찾기 위해 대용량 파일 전체를 컨텍스트에 불러올 필요가 없습니다.
:raw는 바이트가 중요할 때 프로젝션을 우회합니다. :conflicts은 파일 전체에서 병합 충돌 블록을 모델이 직접 찾아야 하는 대신, 미해결 충돌 블록마다 한 줄씩 출력합니다.
범위는 개방형, 길이 기반, 또는 비연속적으로 지정할 수 있습니다.
:50
:50-
:50-200
:50+150
:5-16,960-973
:raw:50-100
:50-100:raw비웹 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();물론입니다. 그랬다면 확장 기능 개발자들이 자체 리더를 구현하거나, 모델이 셸 우회 방법을 찾아내고, 하네스는 web_fetch 같은 별도 이름 아래 유사한 기능을 노출했을 것입니다.
이것이 복잡성을 줄이는 게 아닙니다. 동일한 복잡성이 셸 명령, 프롬프트, 확장 기능, 실패한 도구 호출로 복사될 뿐이며, 아무도 소유하지 않은 채 모두가 30%씩 조금씩 다르게 구현하게 됩니다.
Read이 복잡한 이유는 읽기 작업이 복잡하지 않도록 하기 위해서입니다.
복잡성에는 하나의 소유자가 있습니다. 연산은 안정적으로 유지되고, 리소스별 프로젝션은 그 뒤에서 이루어집니다.
Bash 도구가 단순히 Bash를 셸 아웃해서는 안 됩니다. 황당하게 들릴 수 있습니다.
omp는 완전한 Bash 파서, 인터프리터, 그리고 전체 coreutils를 인프로세스(in-process)로 탑재하고 있습니다. 이것이 좋은 선택이었던 데는 간단한 이유들이 있습니다.
grep를 사용할 수 있고, omp가 인터프리터이기 때문에 명령을 가로채 적절한 인수를 ripgrep 엔진으로 라우팅할 수 있습니다. 모델에게 AGENTS.md에서 rg을 사용하라고 컨텍스트를 소비해가며 설득할 필요가 없습니다.$! 등을 포함해 호출 간에 상태를 유지합니다.더 흥미로운 장점은 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 모드에서 명령을 읽는 Claude가 사람을 상당히 크게 앞섰습니다.
omp가 명령을 직접 해석하면, 실행이 ln에 도달하는 시점에 확인을 요청할 수 있습니다. 그 전까지는 모두 읽기 전용입니다. 사용자가 이미 해당 디렉터리에 쓰기를 허용했다면 이 확인 프롬프트도 생략할 수 있습니다.
이를 통해 하네스는 "Bash"의 TSA 검색대 역할에서 capability 승인자 역할로 전환됩니다. "Git을 사용해 푸시해도 될까요?" find, cat, ln 같은 일반 명령은 인프로세스로 실행되고, 접근 모델을 적시에 조회하며, 사용자의 기존 읽기/쓰기 정책을 그대로 상속받습니다.
호스트가 일반 명령을 직접 해석하기 때문에, 승인은 읽기 불가능한 셸 문자열 경계가 아니라 실질적으로 의미 있는 capability 경계에서 이루어질 수 있습니다. git push, 워크스페이스 외부 쓰기, 네트워크 요청이 그 경계입니다. 3장의 런타임 정책이 모델의 셸 근육 기억을 버리지 않고도 실제로 강제될 수 있게 됩니다.
이 도구는 Anthropic이 자체 버전을 추가하기 한 달 전에 우리 포크에 추가했습니다.
보통 사용자에게 제품 문제를 어딘가에 신고할 방법을 제공하는 것처럼, 이것은 에이전트에게 제공하는 그 동등한 기능입니다. 에이전트가 어떤 도구를 좋아했는지, 무엇이 혼란스러웠는지, 어떤 오동작을 목격했는지에 대한 정보를 완전히 자동으로 수집할 수 있습니다.
보고서의 품질이 완전히 뛰어나지는 않습니다. 예를 들어 Codex는 파일의 외부 편집을 Read나 LSP 도구 탓으로 돌리며 이름 변경이 제대로 안 됐다고 불평하는 경향이 있습니다(내 잘못이 아니라고요, TypeScript 팀에 물어봐요). 하지만 이런 것들은 필터링하기 쉽고, 한번 걸러내면 어떤 도구가 실패하고 어떻게 개선할 수 있는지에 대한 엄청난 양의 신호를 얻을 수 있습니다.
AutoQA는 도구 설계와 실제 배포 동작 사이의 피드백 루프를 닫아줍니다. 노이즈가 많지만, 명백한 오귀인을 걸러내면 어떤 연산이 모델을 혼란스럽게 하는지, 어떤 프로젝션이 필요한 데이터를 숨기는지, 어떤 복구 작업이 하네스에 귀속되어야 하는지를 드러내줍니다.
도구는 이제 한정된 런타임, 안정적인 디스커버리 표면, 구조화된 상태를 갖추었습니다. 모든 도구 개발자—흔히 Claude—가 그 상태를 안전하게 표시하기 위해 터미널 렌더링과 보안 전문가가 될 필요는 없어야 합니다.
ANSI 이스케이프된 문자열 배열과render()는 좋은 렌더링 기본 단위가 아닙니다.
세션 DOM과 도구 상태 스트림은 모든 클라이언트에 동일한 사실을 제공합니다. 하지만 그것만으로 안전하고 빠르며 일관된 인터페이스가 만들어지지는 않습니다. 렌더러는 그 사실들을 재파싱된 문자열, 확장 기능별 스타일링 규약, 되돌릴 수 없는 스크롤백 버그로 바꿔버릴 수 있습니다.
이것은 사실 내가 pi-mono에 올린 첫 PR 중 하나의 주제였습니다. 변경 전에 Pi를 특정 작업 동안 프로파일링해 CPU 사용량을 보면, 목록이 예상하셨겠지만 렌더러로 가득했습니다!
인터랙티브TypeScript CLI라는 사실이 이 중 일부를 불가피하게 만듭니다. 문자열이 내부적으로 UTF-16이라는 것만 해도, 매 프레임마다 비교적 비싼 트랜스코딩 단계를 거쳐야 합니다. 텍스트를 Uint8Array로 들고 다니는 방식이 아닌 한요.
그런데 이 비용을 복합적으로 만드는 것은 계약 자체입니다. 자식 컴포넌트를 삽입하고 싶다면 다음을 처리해야 합니다.
string의 위생 처리 및 ANSI 이스케이프 제거 또는 디코딩이미지가 이 줄들 중 하나에 base64 텍스트로 전달될 수 있다는 사실도 상황을 개선시키지 않습니다. 특정 줄이 이미지 줄인지 확인하는 .includes 검사 하나만으로 세션 전체 CPU 사이클의 20%가 소비됩니다. 가파른 대가입니다(이 세션에는 이미지가 하나도 없었는데도!).
이것은 JS 측의 그래프일 뿐입니다. 이 구조에서 렌더링 파이프라인은 힙을 갈아먹는 기계입니다. 문자열과 문자열 배열을 계속 할당하고, 분해하고, 버립니다. 연결하고, 분리하고, 잘라내고, 패딩하고, 또 반복합니다. 좋지 않습니다.
같은 계약이 확장 기능에 공통 디자인 언어도 없게 만듭니다. 어떤 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`));
}
}여기에는 상당히 많은 문제가 있습니다.
이런 일은 복잡성을 아무것도 모르는 개발자—흔히 Claude—에게 떠넘길 때 자연스럽게 일어납니다.
LLM은 "도구 UI 만들어줘"라는 요청을 받을 때마다 하네스의 내부 세부 사항을 전부 기억하지 못합니다. 솔직히 저도 가끔 기억하기 싫을 때가 있고, 스모크 테스트는 사용 가능한 것으로 통과합니다.
성능, 보안, 일관성 문제는 모두 같은 원인에서 비롯됩니다. 이미 렌더링된 문자열이 레이아웃 트리, 스타일 트리, 콘텐츠, 전송, 터미널 프로그램 역할을 동시에 수행하고 있기 때문입니다.
최하위 소비자(PR을 올리지 않는 한 여러분이 아닌)는 RichText (Style, String)를 전달받은 추상 파이프라인 (&mut impl Out)에 밀어 넣습니다.
이것이 267초의 렌더 시간을 90ms로 줄여줍니다.

render(): string[] — N개 컴포넌트 × M번 변환, 모든 버퍼가 매 프레임마다 재파싱되고, 재측정되고, 재할당됩니다. 이후: RichText는 스트림을 추상 파이프라인에 단일 패스로 흘려 프레임 diff에 직접 도달합니다.임시 객체, ANSI 파싱, 자소(grapheme) 처리—프레임 렌더러 아래의 모든 레이어에서 완전히 사라집니다. 당연히!
컴포넌트에 패딩을 추가해 아래로 전달하는 대신, 패딩을 스트리밍하고 그 다음 줄 하나를 스트리밍하고 반복하면 어떨까요? 255줄 diff를 풀 컬러로 렌더링한 뒤 .slice(0, 3)를 거쳐 다른 문자열 버퍼 배열로 잘라내는 대신, 줄임표 이후의 스트림을 끊거나 변환 과정에서 직접 줄로 나누면 어떨까요?
저수준 기본 단위가 측정과 변환을 한 번만 담당합니다. 상위 레이어는 스스로 내보낸 구조를 파악하기 위해 ANSI를 다시 파싱해서는 안 됩니다.
다음으로, string[]은 제대로 된 컴포넌트 모델로 교체될 예정입니다. 상위 소비자는 단순히 박스를 쌓으며 LSP의 안내를 받을 수 있습니다.

<text> 안에 요소를 중첩하면 런타임에 깨진 프레임이 되는 게 아니라 편집 시점에 린트 오류가 발생합니다.
<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, 웹 클라이언트, 스냅샷 테스트, 원격 인스펙터는 각자의 표면에 맞게 레이아웃을 결정합니다.
컴포넌트 모델은 두 가지 유용한 속성을 공짜로 제공합니다.
<ico:new/>는 모든 플러그인에 편리한 아이콘을 제공하면서 사용자의 ASCII, 유니코드, Nerd Font 선택을 존중합니다. 테두리도 같은 방식으로 동작합니다.info를 요청할 수 있습니다.
border=round bc="info"은 테마의 의미론적 색상으로 해석되고, fg="red..blue"은 그래디언트입니다. 어디에도 테마 객체를 전달하지 않습니다.텍스트 스트림의 속도도 직접 제어해야 합니다. Claude와 Codex는 매우 다른 간격으로 청크를 내보냅니다. 하나는 몇 단어씩, 다른 하나는 몇 글자씩. 이 차이를 평탄화하면 하네스가 얼마나 반응적으로 느껴지는지가 달라집니다. 꾸준한 움직임은 진행 중으로 읽히지만, 폭발적으로 몰렸다가 멈추는 것은 그렇지 않습니다. 당연하죠.
의미론적 아이콘, 테두리, 색상, 잘라내기, 스트림 속도 조절은 이제 하나의 소유자를 갖습니다. 확장 기능은 info, error, <ico:new/>을 요청하면 됩니다. 모든 함수에 테마 객체를 전달하거나 모든 사용자를 대신해 Nerd Font 글리프를 선택할 필요가 없습니다.
현재의 "메타"에서, 비용 없이 가장 높은 ROI를 낼 수 있는 투자는 에이전트에게 어떤 종류의 인터랙티브 TUI / GUI에 대해서든 디버그 프로토콜을 구현하도록 요청하는 것입니다. "어떻게 검증할지"가 불명확하고 명세되지 않으면, 에이전트는 사이드채널로 유사품을 만들어버립니다. 즉, 대부분의 경우 실제로는 아무것도 확인하지 않는 테스트 파일을 만들게 됩니다.
"검증"이 무엇을 의미하는지를 미리 정의하고 편리한 형태로 제공하면, 마찰이 상당히 줄어들어 개발 루프의 능동적인 일부가 됩니다.

형태 자체는 중요하지 않으며 언제든 업데이트할 수 있습니다. 커스텀 도구, Python 패키지, API 무엇이든 될 수 있지만, 비파괴적이고 화면 밖에서 동작하며 다중 인스턴스를 지원하는 무언가를 반드시 제공해야 합니다. 에이전트가 성공의 정의를 (보통은 낮춰서) 재정의하지 못하도록 막기 위해서입니다.
다시 말해, 디버그 프로토콜은 단순한 테스트 도우미가 아니라 UI가 무엇인지에 대한 기계가 읽을 수 있는 정의가 됩니다.
TUI에서 실제로 불가능한 부분은 이에 관한 GitHub 이슈를 0으로 만드는 것입니다. 사람들은 자신이 모르는 것에 대해 이상주의적이며, 안타깝게도 자신이 원하는 완벽한 TUI 경험(위치에 관계없이 모든 컴포넌트가 완전히 최신 상태이고 동적으로 변경 가능한 것)이 불가능하다는 것을 모르는 경우가 많습니다.
정식 트랜스크립트를 블록 목록으로 정의합니다. 블록은 텍스트 행을 생성하며 다음 생명주기를 거칩니다.
활성(active) → 확정(finalized) → 커밋(committed)
살아있는 동안 블록 i는 현재 스냅샷 Wi(행 배열)를 표시합니다. 확정되면 불변 스냅샷 Fi으로 고정됩니다.
블록에는 두 가지 모드가 있습니다.
이 구분은 블록이 뷰포트 할당을 초과할 때 중요합니다. 가변 스냅샷은 나중에 교체될 수 있기 때문에 히스토리에 먼저 들어갈 수 없습니다. 이미 스크롤된 행을 되돌려야 하는 상황이 생기기 때문입니다. 반면 assistant thinking 같은 추가 전용 블록은 안정적인 프리픽스만 확장하므로, 그 프리픽스는 즉시 커밋을 시작할 수 있습니다.
너비 W, 높이 H의 터미널에는 두 개의 버퍼가 있습니다.
기술적으로 스크롤백을 지우고 덮어쓸 수 있지만, 사용자들이 자주 불만을 제기하는 동작으로 이어지기 때문에 이제 불변 조건으로 정합니다.
래핑 wrapW는 논리 행을 물리 행으로 변환하며 현재 너비에 따라 달라집니다. 뷰포트 아래에는 주소 지정 가능한 영역이 없습니다. 하단을 넘어 쓰면 터미널이 스크롤되고 상단 행이 S로 돌이킬 수 없이 밀려납니다.
논리 히스토리 L은 래핑되지 않은 행으로 유지되므로 너비에 독립적입니다. 블록 순서대로 각각 정확히 한 번 커밋된 최종본이 들어가며, 현재 스트리밍 중인 블록에서 이미 통과된 부분도 포함됩니다. 마지막으로 커밋된 블록을 c, j = c+1로 정의하면:
L = F1 · F2 ⋯ Fc · Wj[1..ej]
여기서 ej는 스트리밍 헤드가 이미 히스토리에 내보낸 행 수를 셉니다(ej = 0, 단 블록 j가 스트리밍 중인 추가 전용 블록인 경우 제외).
따라서:
리사이즈는 논리적으로 아무것도 바꾸지 않습니다. 모든 Wi, 모든 Fi, c는 그대로 유지됩니다. 래핑과 뷰포트 할당만 재계산됩니다. 네이티브 스크롤백에 이미 들어간 행은 다시 쓸 수 없으므로, 이에 대한 명시적인 정책이 하나 필요합니다.
이 규칙들은 혼동하기 쉬운 세 가지를 분리합니다. 뷰포트의 가변 프레젠테이션, 너비에 독립적인 논리 히스토리, 되돌릴 수 없는 네이티브 터미널 행. 이것들을 명명하면, 리사이즈와 스트리밍은 민간 전승이 아니라 정책 선택이 됩니다.
이 모든 "수학"을 왜 설명했을까요? 이것은 정합성을 검증하기 매우 복잡한 알고리즘이기 때문입니다. 이전 구현에서는 안정적인 상태에 이르기 위해 퍼저(fuzzer)를 작성해야 했고, 이번에는 그 과정을 피하고 싶었습니다.
대신 설명한 동작을 TLA+로 모델링하고, 명확하게 명세된 불변 조건이 모두 충족될 때까지 블록의 커밋 및 확정 방식을 반복적으로 변경해달라고 요청했습니다.
이제 변경을 하고 싶다면, 예를 들어 부분 커밋을 무작정 시도하거나 블록 잘라내기를 허용하지 않으려 한다면, 업데이트할 참조가 있고 성공 여부를 극히 쉽게 알 수 있으며, 실패 시 반례가 제시됩니다.
논문과 전체 ElasticSlots.tla 소스는 부록 B에 있습니다.
의무적인 자랑이니 보고 넘어가시죠! 이제 누군가 TUI가 고장났다고 불만을 제기하면, 왜 고칠 수 없는지에 대한 형식 증명을 줄 수 있습니다. 좋군요.

TUI, 웹 클라이언트, 원격 인스펙터는 레이아웃이 달라도 진실은 같습니다. 도구 개발자는 의미론적 상태를 기술하고, 컴포넌트 시스템이 프레젠테이션을 담당하며, 트랜스크립트 프로토콜이 정확히 한 번의 히스토리를 보장합니다.
이것은 반복되는 동일한 설계 원칙입니다. 강제할 수 있는 레이어 아래에 어려운 불변 조건을 밀어 넣으세요. 구현 스택은 그 불변 조건을 강화해야 하며, 모든 기여자—그리고 모든 코딩 에이전트—가 각자의 로컬 스타일을 발명하도록 유도해서는 안 됩니다.
에이전트가 유지하고 싶은 코드베이스 방향으로 유도하는 언어 제약을 선택하세요.
앞선 장들은 아키텍처를 다뤘습니다. 언어 선택은 그 아키텍처와 다음에 나타날 "도움이 되는" 로컬 예외 사이에 얼마나 많은 마찰을 코드베이스가 만드는지를 결정합니다. 구현의 상당 부분이 각 생태계의 기본값과 병폐를 학습한 에이전트에 의해 만들어질 때 이것은 더욱 중요해집니다.
TypeScript는 프론트엔드 코드와 반드시 상호작용해야 하는 경우가 아니라면 현재로서는 최악의 선택입니다.
프로젝트를 시작할 때 가장 큰 영향을 미치는 결정 중 하나는 올바른 도구를 선택하는 것입니다. 3년 전 이런 도입부로 시작하는 글을 봤다면 바로 반박했겠지만... 믿기지 않는다면, Claude에게 만들고 싶은 위젯을 묘사하는 동일한 프롬프트를 직접 시험해보세요.
이제 macOS(Swift) → Linux(Qt/JS)로 바꿔보세요. 전자는 OS와 어울리는 글라스모피즘 위젯을 만들어냅니다. 후자는 UI 요소가 겹치고 UX 선택이 의심스러운 직사각형을 내놓는데, UI를 정의하는 XML 스키마를 막 읽고 처음으로 컴파일하는 느낌입니다.
물론 프롬프트 방식도 영향을 미치고 더 자세히 작성할 수 있겠지만, 어떻게 해도 한쪽이 거의 힘들이지 않고 다른 쪽을 앞선다는 것을 결국 알게 될 것입니다. macOS가 역사적으로 잘 해온 것 중 하나는 개발자를 하나의 일관된 디자인 스타일로 강제하는 것이고, LLM에서도 마찬가지입니다.
핵심은 Swift에는 감각이 있고 JavaScript에는 없다는 것이 아닙니다. 기본값, 표준 라이브러리, 정형화된 프로젝트 구조, 컴파일러 피드백, 생태계 관행이 생성 코드의 사전(prior)으로 작용한다는 것입니다. 스무 가지 동등하게 정상적인 로컬 스타일을 허용하는 언어는 모델이 제품 문제에 도달하기 전에 스무 가지 결정을 먼저 내리게 만듭니다.
안타깝게도 TypeScript에서 좋아했던 한 가지는, 결국 항상 자신만의 언어가 된다는 것이었습니다.
camelCase를 쓸까 snake_case을 쓸까? 아니면 그냥 라이브러리 이름을 $로 할까?Buffer을 쓸까 Uint8Array을 쓸까?Array<T>을 쓸까 T[]을 쓸까?.ejs, .cjs, .mjs, .js?)private foo을 쓸까 #foo을 쓸까?module/index.ts을 쓸까 module.ts을 쓸까?const x = () => ..을 쓸까 function x() {을 쓸까?function x(args)을 쓸까 function x(...args)을 쓸까?...args: any[]을 쓸까 ...args: unknown[]을 쓸까?const X = 1을 쓸까 enum E { X = 1 }을 쓸까 const enum E { X = 1 }을 쓸까?C++라는 최고의 write-only 언어와 10년을 보낸 저로서는 이런 점이 오히려 즐겁기도 합니다. 하지만 Zod와 Typebox 중 하나를 강제로 선택해야 할 때, 주니어는 그냥 isRecord을 만들어버립니다. 정신적으로 두 타입 모두 작동하는지 확인하는 대신 왜 typeof로 특수화하면 안 될까요? 클래스를 왜 쓰나요, 어차피 객체와 프로토타입인데요.
세상에 나쁜 JS 코드가 너무 많기 때문인지, 아니면 그 과정에서 난독화된 코드를 잔뜩 학습했기 때문인지 모르겠지만, 이제 지쳤습니다. 같은 주니어가 Linux 제로데이를 찾아낼 수 있다는 점을 고려하면, 올바른 모델이나 올바른 코드 품질 도구에 기대를 거는 것을 멈추고 굳이 어렵게 돌아가지 않기를 권합니다.
어쩌면 EffectJS가 상황을 바꿀 수도 있겠지만, 개인적으로는 결국 Go가 승자가 될 것이라 생각합니다(특히 WASM용 GC 제안이 확정될 때). Swift가 디자인에서 승리하는 것과 비슷한 이유로(특히 컴파일 속도와 크로스 컴파일의 용이성), 다만 일부 경우에는 더 저수준의 시스템 언어가 필요하기 때문에 여기서는 Rust를 선택했습니다.
여전히 목표까지의 최단 경로를 택하도록 꽤 자주 조종해야 합니다. 복잡한 borrow를 다루는 대신 복사본을 할당하거나, thiserror 대신 문자열로 오류를 전달하기도 하지만, std와 serde 생태계가 결합된 환경에서 작동하는 데 필요한 것들은 대부분 갖추고 있으며, 컴파일러가 상당한 수준의 안전성을 제공합니다. 그래서 결정은 그것으로 마무리했습니다.
다음 결정은 확장성을 위해 TypeScript를 다시 도입할지 여부였습니다. 우리는 거절했는데, 주로 다음의 이유들 때문입니다.
eval 도구가 사용자에게 Python 3 설치를 요구하지 않고 즉시 작동하도록 보장할 수 있으며, 우리가 제공하는 플로우에서도 이에 의존할 수 있음@remote 설계를 가능하게 함런타임 장은 @remote 경계를 소개했습니다. Python의 introspection과 attribute 모델이 그 경계를 인체공학적으로 만드는 핵심입니다. SDK는 함수를 검사하고 관련 소스를 패키징한 뒤, 모든 확장 기능 개발자에게 직접 RPC를 작성하도록 요구하지 않고 샌드박스 런타임에서 실행할 수 있습니다.
런타임을 함께 번들링하면 Eval도 사용자가 우연히 호환되는 Python을 설치했을 때만 동작하는 기능이 아니라, 언제나 믿고 쓸 수 있는 내장 기능이 됩니다.
에이전트 하네스는 시스템 소프트웨어다 — fetch를 감싼 while 루프가 아니라.
"왜?"라는 질문으로 이 글을 시작했습니다. 직접적인 답은 이렇습니다. 위에서 다룬 각 장은 수십 년의 선례가 쌓인 소프트웨어 분야입니다. 복제, 샌드박싱, 설정, 스케줄링, 프로토콜 호환성, 실시간 렌더링, 언어 및 런타임 설계가 그것입니다.
omp²는 이 문서를 기준으로 지금도 개발 중이며, 일부는 이미 출시됐고 일부는 아직 구상 단계에 있습니다. 그럼에도 omp를 직접 써보고, 소프트웨어 팩토리를 운영하거나 같은 폰 위에서 카메라 앱을 직접 만들어 달라고 요청하는 등 정말 다양하고 흥미로운 방식으로 활용한 경험을 공유해 주신 모든 분께 진심으로 감사드립니다.
여러분이 omp를 만들어 왔습니다. 앞으로도 이 못지않게 흥미로운 일들이 펼쳐지리라 기대합니다!
상태(state) 장에서는 이 오류들을 유형별로 요약했습니다. 이 부록에는 원본 근거인 소스 링크, 최소 코드 발췌, 재현 영상을 수록합니다.
이는 이론적인 주장이 아닙니다. 공식 확장 기능 예제 78개를 직접 살펴봤습니다. 60개는 상태가 없었고, 상태가 있는 17개 중 올바르게 구현된 것은 단 두 개뿐이었습니다.
/fork에서 사용되기 전에 초기화됨: git-checkpoint.ts출처: 영속 체크포인트 소유권 누락. agent_settled가 stash 참조 맵을 이미 비운 뒤 유휴 상태에서 /fork가 호출됩니다.
const checkpoints = new Map<string, string>();
// …
pi.on("agent_settled", async () => {
checkpoints.clear();
});plan-mode/index.ts출처: session_tree와 getBranch()가 누락됨. 되감기(rewind)를 해도 플랜 모드와 도구 제한이 그대로 활성 상태로 남으며, 재개(resume) 시 삭제된 브랜치의 스냅숏이 되살아날 수 있습니다.
const entries = ctx.sessionManager.getEntries();
const planModeEntry = entries
.filter((e) => e.type === "custom" && e.customType === "plan-mode")
.pop();status-line.ts출처: 브랜치 파생 로직 누락. 3번 턴에서 1번 턴으로 되감으면 다음 턴이 4로 표시되고, 재개하면 다시 0부터 시작합니다.
let turnCount = 0;
// …
pi.on("turn_start", async (_event, ctx) => {
turnCount++;dynamic-tools.ts출처: /add-echo-tool echo_branch는 라이브 확장 레지스트리에만 기록합니다. /tree는 해당 레지스트리를 재시작하지 않으므로 되감기 후에는 도구가 남아 있지만, --continue은 새 레지스트리를 시작하기 때문에 도구가 사라집니다.
const registeredToolNames = new Set<string>();
// …
registeredToolNames.add(name);
pi.registerTool({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) {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") {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.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]);
});tic-tac-toe.ts복원; 사용자 이동: 재구성 과정이 도구 결과만 수용하고 사용자 이동은 커스텀 항목으로 처리합니다. X 이후 O 이전에 크래시가 발생하면 X가 사라집니다.
if (entry.type !== "message") continue;
if (msg.role !== "toolResult") continue;
// User moves take a different path:
pi.appendEntry(SAVE_TYPE, getBoardDetails());인터페이스 장에서는 프로토콜과 결론을 본문 흐름에서 다룹니다. 이 부록에는 트랜스크립트 불변성 검증에 사용한 논문과 전체 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)
====