코딩 에이전트의 하네스를 게임 엔진처럼 바라보세요. 지속 상태, 도구, 설정, TUI, 추론까지 모두 익숙한 엔지니어링 문제로 풀어낼 수 있습니다.
먼저 감사 인사를 드립니다. 수십만 명의 여러분이 omp를 사용하고, 문제를 제보하고, 부족한 점을 제안하며 omp를 만들어 주셨습니다. 이 글도, omp²도 여러분 덕분에 세상에 나올 수 있었습니다.
omp²를 처음 접한 분들 중 상당수가 "왜 굳이?"라고 물으셨습니다.
fetch를 while 루프로 감싸는 일은 간단해 보입니다. 그런데도 OpenCode, Pi, OpenClaw, omp가 동시에 전면 리팩터링에 나선 데에는 이유가 있습니다. 이런 종류의 소프트웨어는 전에 없던 것이고, 단순한 버전부터 만들어 봤기에 비로소 균열이 보였으며, 그래야 더 나은 버전으로 나아갈 수 있기 때문입니다.
피할 수 없는 복잡성에는 주인이 있어야 합니다. 지금은 복잡성 보존 법칙이 익스텐션과 사용자 쪽으로 기울어 있어서, omp나 Pi 위에서는 신뢰할 수 있는 소프트웨어를 만들 수 없습니다. "무슨 소리야, 확장하기 얼마나 쉽고 편한데"라는 반응이 벌써 들리는 듯합니다. 몇 장만 읽어 보시면 생각이 달라지실 겁니다.
다익스트라는 "단순함은 신뢰성의 전제 조건"이라고 썼습니다. 그런데 그는 경로 탐색 문제를 알고리즘으로 풀어낸 사람으로 유명합니다. 무식하게 전수 탐색하면 되는데 왜 그랬을까요? 그는 우리가 지금 단순한 것은 좋고 복잡한 것은 나쁘다는 식으로 되뇌는 주장을 결코 한 적이 없습니다. 이 조언은 구현하는 사람이 제대로 추론할 수 있도록 돕기 위한 것이었는데, 우리는 부끄럽게도 추론을 건너뛰는 핑계로 써 왔습니다.
빠져 있던 나머지 절반은 오스터하우트가 스탠퍼드 강의 노트에서 채워 줍니다. 그는 모듈을 만드는 사람에게 "고통을 받아들이라"고 말합니다. 어려운 문제를 맡아 끝까지 풀고, 그 결과를 다른 모든 사람이 쉽게 쓸 수 있게 만들라는 뜻입니다. 복잡성은 모듈 안쪽으로 밀어 넣으세요. 호출하는 쪽마다 조금씩 다른 축소판을 각자 떠안게 하지 말고, 소수의 구현자가 대신 짊어지면 됩니다.
Claude Code를 게임 엔진에 빗댄 트윗이 밈으로 번졌던 일을 기억하시는 분이 많을 겁니다. 억지스러운 비유처럼 들리지만, 렌더링은 제쳐 두고 하네스의 책임을 쭉 늘어놓아 보면 꽤 잘 들어맞습니다.
하네스는 권위 있는 월드를 유지하고, 변경 사항을 저널에 기록하고, 신뢰할 수 없는 동작을 실행하고, 상태를 여러 뷰에 복제하고, 액터를 스케줄링하고, 명령을 해석하고, 서로 맞지 않는 프로토콜을 이어 붙이고, 실시간 인터페이스를 렌더링합니다.
어디서 많이 본 목록 아닌가요? 게임 엔진은 수십 년 동안 똑같은 종류의 복잡성을 도맡아 온 셈입니다.
이어지는 내용은 사후 분석이자 플레이북입니다.
먼저 운영 모드를 정하세요. 모든 서브시스템은 이 모드를 전부 견뎌내야 합니다.
에이전트 하네스의 서브시스템을 논하기 전에, 서로 전혀 다른 네 가지 제품이 이 하네스에 의존한다고 상상해 보세요.
이들은 각각 하네스를 단순한 채팅 루프에 머물지 못하게 만드는 서로 다른 차원을 압박합니다.
| 구분 | 로컬 또는 원격 | 대화형 또는 자율형 | 신뢰 경계 | 동시성 |
|---|---|---|---|---|
| 멀티플렉스 워크스페이스 | 로컬 | 대화형 | 대체로 신뢰 가능 | 여러 에이전트, 하나의 워크스페이스 |
| 리모트 드라이버 | 원격 | 대화형 | 호스트/클라이언트 분리 | 에이전트 하나 또는 여러 개 |
| 관전자 | 원격 뷰 | 관찰 전용 | 신뢰할 수 없는 표시용 입력 | 다수의 뷰어 |
| Factorio | 원격 또는 플릿 | 자율형 | 악의적인 저장소 및 도구 입력 | 다수의 작업 |
첫 번째 경우만 염두에 두고 설계하면 컨트롤러를 TUI 안에 슬쩍 밀어 넣고, 상태를 클로저에 두고, 익스텐션을 엔진 프로세스 안에서 실행하고, 끝이 정해지지 않은 호출에서도 사람이 복구해 주리라 가정하기 쉽습니다. 네 가지를 모두 견디는 설계라면 더 나은 경계를 둘 수밖에 없습니다.
이 글의 나머지는 다섯 가지 귀결을 따라갑니다.
이 글 뒷부분에 나오는 DOM, convar, Director, VM 스텁, 컴포넌트 렌더러는 모두 이 다섯 가지 중 하나를 충족하기 위해 존재합니다.
권위 있는 상태를 저널에서 도출할 수 없다면, 되감기·포크·재개는 모두 거짓말입니다.
어떤 것을 영속적이고, 되감을 수 있고, 크래시에 견디고, 포크할 수 있게 만들고 싶다면 선택지는 세 가지입니다.

Source Engine은 네트워킹에 두 번째 방식의 변형을 씁니다. omp와 Pi는 현재 셋 중 어느 것도 일관되게 따르지 않습니다. 이벤트는 있지만 상태가 그 이벤트에서 실제로 도출되지는 않습니다. 이벤트 소싱의 제1원칙인 상태는 이벤트만으로 도출할 수 있어야 한다를 어기고 있는 셈입니다.

replay(.dem) == original입니다. 반면 Pi의 저널은 메시지 트리만 다루고 권위 있는 상태는 그 바깥에 있어서, 되감기·포크·재개가 모두 거짓말이 됩니다.이런 구조가 된 데에는 납득할 만한 이유가 있었습니다. 시스템 프롬프트와 AGENTS.md를 로그마다 반복해 저장하면 낭비이기는 합니다. 이는 템플릿을 해시하고 변수만 저장하는 방식으로 풀 수 있습니다. 게다가 이런 식의 상태 모델링은 런타임 타입이 사실상 없는 TypeScript에서는 흔하지 않습니다.
그래도 결과적으로는 진실의 원천이 여전히 둘입니다.
| Source Engine | Pi 방식 하네스 | |
|---|---|---|
| 진실의 원천 | entity list, 그게 전부입니다. 서버가 시뮬레이션하고 클라이언트는 예측합니다. | 메시지 트리 그리고 할 일(todo) 상태, 재시도 카운터, 서브에이전트 레지스트리, 스트리밍 플래그 등 영속화에서 보이지 않는 상태 |
| Δ의 단위 | { Δ entity ... }. 모든 델타가 엔티티 델타이므로 모든 필드를 포괄합니다. | message / custom / custom_message. 엔진이 제공하는 폴드(fold)가 없어서 익스텐션마다 도출 로직을 직접 구현해야 합니다. |
| 전역 상태 | CCSGameRules는 싱글턴 엔티티입니다. 예외 규칙이 없습니다. | 3단계 계층 중 제대로 동작하는 것은 하나뿐 |
| 플러그인 상태 | 플러그인이 엔티티 필드에 쓰므로 상태가 기본적으로 네트워크로 전달되고 재생됩니다 | 모듈 수준 클로저: let turnCount = 0, new Map(), new Set() |
| 재생 | .dem를 불러오고, 특정 틱으로 이동한 뒤 다시 도출합니다 | .jsonl를 불러옵니다. 리프 포인터는 움직이지만 나머지 권위들은 제각각 초기화되거나 살아남습니다 |
전역 상태 행이 특히 웃깁니다. Source에는 세션 전역 변수가 따로 없고, 전부 엔티티의 속성일 뿐입니다. 반면 우리 쪽에는 전역 상태만의 계층 구조가 있습니다.

Source가 여기에 이른 방법은 정교한 리컨실러나 좋은 문서가 아니었습니다. 재생할 수 없는 상태를 아예 표현할 수 없게 만든 것입니다. 그래서 익스텐션 작성자가 훅 두 개를 등록하고 업데이트 형태를 정의해야 한다는 사실을 기억할 필요가 없습니다.
Pi 공식 익스텐션 예제 78개를 살펴봤습니다. 60개는 상태가 없었고, 상태가 있는 17개 중 올바르게 동작하는 것은 두 개뿐이었습니다.
| 예제 | 권위에서 벗어난 상태 | 사용자가 보는 실패 |
|---|---|---|
git-checkpoint.ts | 일시적인 Map가 소유한 체크포인트 참조 | agent_settled가 체크포인트를 이미 지운 뒤에 /fork가 실행됨 |
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>이 DOM의 이벤트는 속성 변경 스트림입니다.
: todo.done
event: patch@1
by: e41
data: {"ops":[["set",412,"status","completed"],["set",415,"status","in_progress"]]}권위는 트리가 갖고, 저널에는 그 증분 변경만 저장합니다. 런타임 객체가 트리를 캐시하거나 인덱싱할 수는 있지만, 진실이 머무는 두 번째 장소가 되지는 않습니다. 저널의 어느 시점에서든 하네스는 세션 전체를 구체화할 수 있고, 따라서 스냅샷도 찍을 수 있습니다.
상태와 대화 기록이 한 트리에 있으면 까다로운 문제 여럿이 같은 연산으로 정리됩니다.
되감기는 DOM diff입니다. 현재 구체화된 상태를 목표 상태와 비교하세요. <subagent> 요소가 사라졌다면 요소를 파기해 종료하면 됩니다. 새로 생겼다면 요소를 만들어 재개하거나 생성하면 됩니다. 델타 자체가 곧 라이프사이클 작업 목록 전체입니다.
상태를 가진 기능을 추가해도 되감기, 포크, 재개, 복제에 호출 지점이 늘어나지 않습니다.
프롬프트는 프로젝션이 됩니다. 모든 템플릿에 100줄짜리 상태 객체를 넘겨 줄 필요가 없습니다. 시스템 프롬프트도 다른 모든 것과 같은 트리를 읽습니다.
- {{ count(select("todo item[status!=completed]")) }} open items복제는 구독이 됩니다. 적용 로직과 도출 로직은 이미 있습니다. 리모트 클라이언트는 파일을 tail하는 대신 패치 스트림을 소비하면 됩니다. 리모트 드라이버와 관전자 시나리오에서도 별도의 상태 배관이 필요 없습니다.
렌더링은 프로젝션이 됩니다. 컴포넌트 레지스트리는 같은 요소 상태에서 Read, Bash, 메시지, 서브에이전트를 렌더링할 수 있습니다. 인자가 스트리밍되면 <input>이 바뀌고, 출력이 스트리밍되면 <result>이 바뀝니다. 자세한 내용은 인터페이스 장에서 다룹니다.
이렇게 분리하면 서브에이전트도 들여다볼 수 있게 됩니다. Pi의 뷰는 실시간 세션 상태를 직접 읽습니다. 푸터가 sessionManager.getEntries()를 호출하는 식이어서, "서브에이전트 검사" 기능을 추가하려면 UI 내부로 컨트롤러 상태를 끌어와야 합니다.
컨트롤러와 액터를 완전히 분리하세요. 컨트롤러가 세션 상태를 소유하고, 액터는 그 스냅샷과 패치 스트림을 렌더링할 뿐입니다. TUI, 리모트 클라이언트, 서브에이전트 인스펙터는 동등한 피어가 됩니다. 자식 에이전트를 검사한다는 것은 같은 액터가 자식의 상태를 바라보게 한다는 뜻입니다.
정책은 신뢰할 수 있는 호스트에 두고, 샌드박스 안에는 대역폭이 제한된 실행 스텁만 넣으세요.
설계 범위에서 소개한 Factorio 사례부터 봅시다. roboomp를 클론해서 gpt spark에게 이름을 전부 CodeWhatever로 바꿔 달라고 시키고, 우리만의 마법 같은 기술이라며 수천 달러씩 받는다고 해 봅시다. 도구는 누가 실행할까요? VM이죠, 당연히. 네, 그렇겠죠.
실행기를 VM 안에 두면 이런 일이 벌어집니다.

음, 이건 안 되겠네요. 이유는 이렇습니다.
좋아요, 그럼 구동하는 앱을 VM 안에 넣어 봅시다!

해법은 VM 안에 시키는 대로만 하는 스텁 하나를 두고, 되돌려 보내는 스트림의 최대 데이터량을 아주 엄격하게 제한하는 것입니다(잘못 쓴 Read 도구 하나가 2GB짜리 응답을 만들어서는 안 되니까요).

역할 분담은 다음과 같습니다.
로컬 사용도 나빠지지 않습니다. 같은 호스트가 스텁을 로컬 프로세스, 컨테이너, VM, 원격 머신 어디에든 연결할 수 있습니다.
배치는 호스트 대 VM 구도만이 아닙니다. 서브에이전트에도 파일시스템 계층에서 같은 경계가 필요합니다. 워크트리는 추적 중인 파일만 격리하지만, pi-iso는 APFS, btrfs, ZFS, overlayfs, ProjFS를 이용해(없으면 복사로 대체) 각 자식에게 워크스페이스 전체의 카피 온 라이트 뷰를 제공합니다. 자식은 독자적으로 갈라져 나가고, 부모는 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);
},
};작고 쓰기 편하지만, 하나의 동작이 서로 무관한 세 콜백으로 쪼개져 있습니다. 미리보기, 실행, 모델용 결과, 사람용 결과, 진단, 스트리밍 업데이트, 취소, 저널 기록은 모두 같은 호출을 설명하는데도 말입니다.
첫째, 렌더러 경로를 분리하면 반응성이 옵트인이 됩니다. 렌더링되는 도구가 새 형태로 "딱" 바뀌지 않더라도, 작성자는 표시 로직의 상당 부분을 중복해서 작성해야 합니다.
더 큰 문제는 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 속성으로 제어해야지, 잘라내기를 좋은 설계를 위한 옵트인으로 둬서는 안 됩니다. 헬퍼를 선택 사항으로 두면 두 가지 방식으로 실패합니다.
대부분의 도구에는 어느 정도 잘라내기가 필요하므로, 옵트인 헬퍼는 적용 범위가 고르지 않을 수밖에 없습니다.
대화 렌더링 계층이 아니라 도구 구현 안에서 잘라내면 Code 모드가 깨집니다.
Eval 안에서 도구 출력을 신뢰할 수 없습니다. 쓸 때마다 먼저 데이터에서 하네스 알림을 걷어내야 합니다.Eval 결과 자체도 잘릴 수 있으므로, 호출 한 번이 같은 데이터를 N+1겹의 독립적인 잘라내기 계층으로 감싸게 됩니다.무엇이든 백그라운드로 보내는 기능과 호출이 블로킹할 수 있는 시간의 상한 역시, 우연히 오래 걸리는 도구마다 구현할 것이 아니라 라이브러리 계층에 있어야 합니다.
첫 번째 이유는 캐싱과 UX입니다. 예상치 못하게 긴 호출이 있으면 에이전트는 이를 알아채고 대응하지 못하고, 사용자는 멈춰 버린 세션으로 돌아오고, 자율 작업은 영원히 기다리고, 호출이 돌아오기 전에 프로바이더의 KV 캐시가 만료됩니다.
두 번째 이유는 중복이며, 이 부분은 omp도 잘못했습니다. 도구마다 자체 백그라운드 기능을 키우면 도구마다 자체 spawn, poll, message, kill, list 헬퍼도 따라서 생깁니다. Claude가 자신의 Task와 Bash 도구를 두고 그린 다음 다이어그램을 보세요.

둘 다 결국 프로세스의 인터페이스인 signal + stream in + stream out로 수렴합니다. 백그라운드로 보낸 셸, 서브에이전트, 개발 서버 데몬, 원격 함수, 예산을 넘겨 버린 평범한 호출은 모두 같은 객체입니다. stdin, stdout, 종료 상태, 시그널 핸들을 가진 작업(job)이죠. stdio 형태의 작업 프리미티브 하나로 이 모두를 감싸야 합니다. 그러면 블로킹 예산은 한 곳에서 강제하고, 출력은 하나의 아티팩트 경로로 넘치게 하고, 이들 중 무엇이든 조회하고, 메시지를 보내고, 종료하는 일을 도구별 사본이 아닌 하나의 인터페이스로 처리할 수 있습니다.
관측성에 대한 기대도 같은 방향으로 수렴합니다. 서브에이전트 상태를 보고 싶은 사용자는 백그라운드 셸도 보고 싶어 합니다. 하네스 인스턴스를 넘나들며 피어에게 메시지를 보내는 에이전트는 그 피어들이 띄운 데몬도 보고 싶어 하고, 그래야 한 디렉터리의 N개 에이전트가 N개 포트에 N개 복사본을 띄우는 대신 HMR bun dev 하나를 공유할 수 있습니다.
익스텐션, 그리고 그에 따라 커스텀 도구가 엔진의 JavaScript 아이솔레이트를 공유하면 재앙으로 이어집니다. 제대로 된 핫 리로드는 거의 불가능해지고, 협조적 취소를 벗어난 도구 호출은 강제로 멈출 수도 없습니다.
JavaScript와 Go는 AbortSignal와 context.Context로 취소를 노출합니다. 유용한 프로토콜이지만 강제되지는 않습니다. 시그널 전달을 깜빡하거나, 시그널을 받지 않는 의존성을 호출하거나, 동기 작업을 돌리거나, 무한 재시도 루프에 들어가면 타임아웃은 에이전트에게 계속 진행하라고 알려 줄 뿐입니다. 작업 자체는 백그라운드에서 계속 리소스를 태울 수 있습니다.
그래서 안전한 호스트에는 실제로 종료시킬 수 있는 실행 단위가 필요합니다. 프로세스, 워커, 서브인터프리터, VM 요청처럼 그것이 죽어도 세션 권위가 함께 죽지 않는 경계여야 합니다.
일부러 단순하게 만든 샌드박스 스텁은 마지막으로 SDK 문제 하나를 낳습니다. 익스텐션 작성자에게 파일시스템이 두 개로 보인다는 점입니다. 커스텀 edit 함수는 한쪽에서 파일을 읽어 통째로 옮기고, 다시 다른 쪽에 써야 할 수도 있습니다.
omp²가 익스텐션 언어로 Python을 고른 이유가 여기에 있습니다. Python은 표준 라이브러리로 자기 AST를 들여다볼 수 있고, 함수에 필요한 소스를 패키징해 다른 런타임에 제출할 수 있습니다. @remote 속성 하나로 로컬 함수처럼 보이는 함수를 RPC로 바꿀 수도 있습니다. Modal의 Python SDK 같은 시스템에서 원격 함수가 자연스럽게 느껴지는 것도 같은 특성 덕분입니다.
Python 런타임을 함께 제공하면 Eval도 어떤 인터프리터가 깔려 있는지에 좌우되지 않고 믿고 쓸 수 있습니다. 일석이조입니다.
여기에는 두 종류의 제어가 있습니다. 값은 어떤 모델, 티어, 테마, 정책이 활성화되어 있는지입니다. 동작은 에이전트가 턴을 넘겨도 되는지, 한 턴을 더 진행해야 하는지, 잠시 어떤 기능이 필요한지입니다. 호출하는 쪽마다 자기 setter나 플래그를 가지면 둘 다 엉망이 됩니다.
설정의 범위, 영속성, 상속, 복제 여부는 그 설정을 정의하는 곳에서 선언하세요.
설정 시스템도 지뢰밭이 되었습니다. dirty 추적이 필요하고, 설정 레벨도 전역, 세션, 일시적 설정 등 여러 단계였습니다. 변경 사항을 JSONL에 영속화해야 하기 때문에, Pi에서처럼 대부분의 get/set이 AgentSession 타입을 거쳐 처리되었습니다.
이 문제를 수년 전에 모두 해결한 설정 시스템이 무엇인지 아시나요? 네, Source Engine입니다!
특히 놀라운 점은 Valve 게임을 만져 본 사람 대부분이 sv_cheats가 무슨 일을 하는지 바로 안다는 것입니다. 오랜 세월 사람들이 각자 환경을 커스터마이징해 왔는데도, 불만스러워하는 사용자를 저는 단 한 명도 기억하지 못합니다. 다른 소프트웨어의 다른 설정 중에 그런 게 떠오르시나요?
convar는 이름, 기본값, 도움말 문자열, 플래그 비트필드를 가진 타입 있는 변수로, 정의하는 곳에서 한 번만 선언합니다.
ConVar sv_gravity("sv_gravity", "800", FCVAR_REPLICATED | FCVAR_NOTIFY, "World gravity.");영속성, 소유권, 범위, 복제, 심지어 재생 정직성(replay-honesty)까지 모두 변수의 속성이며, 변수가 태어나는 곳에 명시됩니다. set를 갓 오브젝트(god object)에 통과시키는 사람도, dirty 추적을 손수 구현하는 사람도 없습니다.

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 덕분에 더 좋아집니다. bind, toggle, alias도 콘솔 명령이므로, 우리가 매번 스키마를 새로 만들던 모든 입력 패턴이 인밴드로 유지됩니다. 사용자가 키 하나로 생각(thinking) 표시를 숨기고 싶다면?
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);
});
}아하! 두 구현체는 같은 작성자가 만들었고, 그 사람이 이 문제를 겪고서 자기 플러그인 모음 안에서만 통하는 해법을 만든 것입니다.
이런 동작을 하나로 묶는 시스템을 도입하는 복잡성이 플러그인 작성자에게 떠넘겨졌고, 그들은 자기 익스텐션끼리만 통하는 시스템밖에 만들 수 없습니다.
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 리마인더는 yield하기 전에 마지막으로 이의를 제기하고 싶어 합니다.
그래서 에이전트 계층에 이 결정을 소유하는 객체 하나, 즉 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를 제거하고, 재개는 Director를 복원하며, 리모트 인스펙터는 지금 어떤 동작이 후보 yield를 쥐고 있는지 볼 수 있습니다.
플랜 모드가 활성화된 상태에서 모델이 플랜 파일을 쓰지 않고 yield하려 한다고 합시다. Plan은 바깥쪽 어떤 동작보다 먼저 그 후보 yield를 봅니다.
class Plan(Director):
async def on_yield(self, agent, turn):
if not turn.wrote(self.plan_file):
return agent.force_tool(
"write",
until=lambda turn: turn.wrote(self.plan_file),
reminder="Write the plan file before yielding.",
retries=3,
)
if not turn.called("ask") and not turn.proposed_plan():
return agent.force_tool(
"required",
until=lambda turn: turn.called("ask") or turn.proposed_plan(),
reminder="Propose the plan, or ask the user what is missing.",
retries=3,
)
return Yield()이제 force_tool("write")가 소프트 모드에서 작은 내장 Director를 push하고, 이 Director가 다음 추론 요청에 해당 기능을 얹어 줍니다.
class ForceTool(Director):
def prepare_inference(self, request):
return request.with_tool_choice(self.tool)
async def on_yield(self, agent, turn):
if self.until(turn):
return Done() # pop; offer the yield back to Plan
if self.retries_left:
return Continue(self.reminder)
return Fail("tool requirement exhausted")Plan 아래에는 스택에 다른 Director가 이미 하나 있습니다.
Base → TodoReminder → Plan후보 yield는 Plan에 가장 먼저 도달합니다. Plan이 활성인 동안 Plan은 계속 진행하거나, 자식을 push하거나, 사용자에게 직접 yield합니다. Pass는 하지 않으므로 바깥쪽 TodoReminder는 그 yield를 보지 못합니다.
익스텐션도 완전히 같은 인터페이스를 씁니다.
await agent.direct(VerifyBeforeYield(...))<directors>
<todo-reminder id="d1">
<plan id="d2" plan-file="local://auth-plan.md">
<force-tool id="d3" tool="write" attempts="1" max-attempts="3"/>
</plan>
</todo-reminder>
</directors>Plan이 yield를 소유하고, 한동안 ForceTool을 push하고, 자식이 끝나면 같은 후보 yield를 돌려받아 계속 진행하거나 사용자에게 반환합니다.
이것으로 plan, goal, vibe, autoresearch, 리마인더, 외부 검증이 모두 해결되며, 어느 것도 서로의 플래그를 알 필요가 없습니다.
ForceTool는 "다음 성공한 턴에서 write을 호출해야 한다"라는 의미 수준의 요청을 표현합니다. 선택된 프로바이더가 네이티브 tool_choice를 지원하는지, 강제하면 캐시가 깨지는지, 로컬 모델에 추가 프롬프트가 필요한지는 알지 못합니다. 그 변환은 추론 계층의 몫입니다.
모델의 특이 동작은 명시적인 우선순위를 가진 하나의 규칙집에 모아 두세요.
이 부분은 omp v1에 이미 전후 커밋이 있어서 설명하기 쉽습니다.
dd57045396 이전에는 OpenAI 호환 로직이 거대한 빌더 하나를 중심으로 한 880줄짜리 파일 하나에 몰려 있었습니다. 파일을 열면 이런 코드가 반깁니다.
const isCerebras = modelMatchesHost(hostModel, "cerebras");
const isZai = modelMatchesHost(hostModel, "zai");
const isKimiModel = isKimiModelId(spec.id);
const isMoonshotKimi = isKimiModel && isMoonshotNative;
const isAnthropicModel =
modelMatchesHost(hostModel, "anthropic") ||
isClaudeModelId(spec.id) ||
isAnthropicNamespacedModelId(spec.id);
// …then DeepSeek, Qwen, MiMo, Grok, Mistral, OpenCode, local servers이 불리언들이 다른 불리언들로 이어지고, 중첩 삼항 연산자 몇 개를 거쳐 마지막에는 거대한 compat 객체 하나가 됩니다. Kimi는 생각하는 동안 도구를 강제할 수 있을까요? 어느 호스트의 어느 API를 거치는 어떤 Kimi냐에 따라 다릅니다. 이 루프백 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의 thinking은 이렇게 읽힙니다.
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 리비전은 예산 기반 thinking을 쓰고, Anthropic 4.7 이상은 적응형 thinking을 쓰며, 확인을 마친 호스트에서만 이를 주장한다는 것입니다.
KDL 자체에 마법이 있는 것은 아닙니다. 같은 난장판을 더 예쁜 형식으로 다시 만들지 않게 해 준 것은 컴파일러입니다.
그래서 프로바이더가 덜 이상해졌을까요? 당연히 아닙니다. 여전히 requires-mistral-tool-ids, qwen-preserve-thinking, strip-deepseek-special-tokens 같은 이름의 호환성 축이 있고, "추론 끄기"를 표기하는 방식도 열 가지나 됩니다. 이름을 보면 눈물이 납니다.
이 방식이 막아 준 것은 다음 특이 동작을 네 개의 서로 다른 함수에 또 하나의 분기로 추가하는 일입니다. 이제는 사실을 소유한 곳에 규칙 하나만 두면 되고, 우선순위가 모호하면 컴파일러가 소리를 지릅니다. 덕분에 추론 계층은 마침내 이 질문에 답할 수 있게 됩니다. 정확히 이 모델이 정확히 이 호스트에서 실제로 무엇을 지원하는가?
stream 이상이다이 문제는 제가 Pi 플러그인으로 웹 검색을 구현하는 순간 다시 나를 괴롭힐 것이 거의 확실했습니다. 실제로 같은 압박이 저장소의 미니멀한 출발점에도 닥쳤습니다. Pi의 새 이미지 모델 구현을 보면 알 수 있습니다.
Pi는 프로바이더를 stream와 streamSimple로 모델링하고, 사실상 그게 전부입니다! 프로바이더를 빠르게 세우기에는 좋지만, 그 위에 점점 더 많은 것을 쌓기에는 좋지 않습니다. 이런 것들은 어떻게 하나요?
이 중 하나를 하는 익스텐션마다 동기화된 OAuth 갱신과 재시도까지 제대로 구현했을 것 같으신가요?
그뿐 아니라 추론 프로바이더가 지원하는 최신 제어 기능을 쓸 수 있다는 것은 큰 이점입니다. 몇 가지 예를 들면 다음과 같습니다.
도구 호출 강제를 보면 "플래그를 지원한다"는 것만으로는 왜 부족한지 알 수 있습니다.
이상적인 하네스 구현은 다음과 같습니다.

이것이 ForceTool의 추론 쪽 절반입니다. Director가 무슨 일이 일어나야 하는지 말하면, 프로바이더 계층이 가장 저렴한 방법을 골라 거기에 도달하고, 모델이 따르지 않으면 단계적으로 강화합니다.
도구의 parameters 필드는 인자의 형태를 엄격하게 정의합니다. 사람이 쓰는 API라면 이상적이겠지만, 모델은 범용 API 클라이언트가 아닙니다. 모델의 실수는 도구 이름과 학습 데이터에 나타난 하네스에 따라 다른 경우가 많습니다.
RL로 극한까지 학습된 에이전트는 익숙한 도구를 다른 하네스의 스키마로 호출하기도 합니다. Composer 모델은 Grep 도구가 없는데도 자기가 기대하는 형태로 Grep를 내보내기도 합니다. Codex는 paths: string[]를 보고 그날 기분에 따라 ; 또는 ,로 구분한 문자열 하나를 보내기도 합니다.
따라서 라이브러리는 검증하는 데서 끝나지 않고 교정도 해야 합니다. 도구의 의미 계약에는 엄격하되 모델의 방언에는 관대해야 합니다. 대응 관계가 모호하지 않다면 paths: "a,b"를 리스트로 고쳐 주고, 그렇지 않으면 재시도할 수 있는 구조화된 오류를 반환합니다. JSON Schema 검증기만으로는 이 계층을 감당할 수 없습니다.
제약 샘플링은 Pi에 가장 먼저 추가한 기능 중 하나였습니다.
+ strict?: boolean;
+ customFormat?: { syntax: "lark" | "regex"; definition: string };
+ customWireName?: string;몇 달 뒤 Pi도 LARK와 strict를 지원했지만, 프로바이더 계층이 그대로 통과시키는 불투명한 구조로 노출했습니다. 시스템 전반에 걸친 두 가지 제약 때문에 이것만으로는 부족합니다.
겉보기에 "복잡한" 이 구현이 추론 계층에 있어야 하는 이유가 바로 이것입니다.

strict을 제대로 지키려면 프로바이더의 지원 여부, 우선순위를 둔 strict 스키마 예산, 방언별 정규화, 클라이언트 측 복구 경로가 필요합니다. 불투명한 pass-through 구조체로는 어느 것도 해낼 수 없습니다.추론 라이브러리가 해야 할 일은 더 있습니다.
tool_call 블록과 think 블록 합성
도구 호출 쪽 이야기는 이전 글에서 다뤘습니다. 프로바이더나 모델을 지원하려면 URL을 연결하는 것만으로는 부족하고, 각자의 고유한 특성까지 처리해야 합니다.
한도에 닿기 전에 저널 스냅샷을 기준으로 요약을 미리 시작하고, 한도에 도달했을 때 스냅샷이 여전히 해당 브랜치를 반영하는 경우에만 커밋하세요.
단순한 설계는 사용자 경험도 최악입니다. 세션에서 가장 큰 요청이 하필 사용자가 작업에 가장 몰입한 순간에 나가고, 사용자는 그 응답을 기다려야 하니까요.
Snapcompact 같은 방법을 쓰더라도 개선할 여지는 아직 많습니다.

대신 한도에 도달하기 약 10% 전에 컴팩션을 선제적으로 시작해 보세요. 대화를 두 개의 동시 버전으로 갈라 놓는 셈입니다. 한쪽에서는 사용자와 모델이 하던 작업을 계속하고, 다른 쪽에서는 모델이 대화를 컴팩션합니다.

응답이 오면 그 결과를 작업을 이어 온 다른 브랜치에 끼워 넣습니다. 이렇게 하면 작업의 흐름도 유지됩니다. 히스토리에 핸드오프 메시지만 덩그러니 남아 모델이 혼란스러워하는 일이 없고, 어차피 했어야 할 진행 내용을 모두 볼 수 있기 때문입니다.
프롬프트 방식 외에 고려할 만한 방법은 다음과 같습니다.
이 부분은 UI 렌더링과 요청 렌더링을 분리하는 추상화에서도 고려해야 합니다. 사용자는 히스토리를 볼 때 모든 메시지가 그대로 남아 있기를 기대하지만, 모델에게는 그 메시지들이 존재하지 않기 때문입니다. 따라서 요청 fn(this, req) -> req를 만들 때 프롬프트 히스토리의 각 항목을 "fold"로 모델링하고, <Handoff> 구현 안에서 처리해야 합니다.
작은 로컬 모델은 정말 유용합니다. 평소 프런티어 모델만 쓰더라도 임베디드 tiny 모델(특히 LiquidAI 모델을 확인해 보세요)을 구현해 두길 권합니다. 분류 작업이나 제목 생성, 번역, 대화가 흘러가는 방식에 사용자가 얼마나 만족하는지 판단하는 같은 작은 작업에서 지연 시간과 비용을 크게 아낄 수 있습니다. TTS/STT 역시 로컬에서 이미 SoTA 성능을 낼 수 있는 활용 사례입니다.
상시 도구는 모두 매 턴마다 비용을 발생시킵니다. 목록은 작게 유지하고, 각 프리미티브는 깊게 만드세요.
대부분의 도구를 모델에 보여 주는 가장 좋은 방법은 상시 도구 목록에 아예 넣지 않는 것입니다.
얼마 전 같은 작업에서 omp가 codex보다 느리다는 불만을 받았습니다. 토큰이 아니라 실제 소요 시간(wall-clock) 기준이었습니다. 별일 아닐 거라 생각했는데, 놀랍게도 사실이었습니다. 거의 두 배나 차이가 났습니다.

sol, 매번 새 세션으로 6회 실행한 중앙값 · 청록색 = omp 변형, 회색 = 외부 비교 대상 · 주석은 바로 위 행 대비 차이입니다.원인은 도구 목록이었습니다. 필수 도구 다섯 개로 제한하면 36.6s가 나오는데, 이는 Codex의 42.2s, Pi의 37.0s보다 빠릅니다. 이유는 바로 도구 문법입니다. 모델 입장에서는 텍스트 설명일 뿐이지만, 대부분의 프런티어 모델 프로바이더에서는 이것이 토큰 생성 과정에 적극적으로 관여합니다. 항상 유효한 JSON을 내놓도록 유도하는 것인데, 설명에 쓰이는 토큰은 별도로 듭니다.
도구는 모델이 혹시 필요할지 모르니 넣어 두는 공짜 이득이 아닙니다. 동적 도구 탐색이 바로 이런 발상입니다. 그러나 이 동적 방식은 도구 목록이 바뀌는 순간 캐시가 무효화되므로, 저희는 별로 선호하지 않습니다.
Pi가 옳게 짚은 점이 하나 있고, 저희도 늘 동의해 왔습니다. MCP는 설계가 끔찍해서 상시 도구 계층에 둘 물건이 아니라는 것입니다. 그렇다면 Figma MCP를 원하는 사용자와 추론 제약을 어떻게 함께 만족시킬 수 있을까요?
dyn CLI를 소개합니다. 실제 CLI는 아니고 저희 Bash 구현이 제공하는 빌트인입니다. 모델에게 안정적인 탐색 프로토콜을 제공하고, Bash나 Python 함수인 Eval를 통해 편리하게 쓸 수 있게 해 줍니다.
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 매핑을 만드는 데는 JSON 스키마만으로 충분합니다.
입력이 클 때 특히 편리합니다.
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을 노출합니다. 안정적인 스키마 하나로 한 번의 호출 안에서 작업을 조합하는 방식입니다. 작업 집합이 한정되어 있으면 스키마, 열려 있으면 코드 표면을 쓰세요. 어느 쪽이든 탐색을 마친 뒤 상시 목록은 바뀌지 않습니다.
계약에 작은 변경이 하나 있습니다. 모든 도구가 i intent 인자를 받습니다. 이 인자는 인자 스트리밍 중에 도착하므로, 호출이 끝나기 전에 renderCall가 모델이 무엇을 하려는지 보여 줄 수 있습니다. 저널에도 읽기 쉬운 요약이 남으니, 도구마다 reason / purpose을 따로 만들 필요가 없습니다.
도구에는 버전을 붙이는 것이 좋습니다.
트레이스를 훨씬 쉽게 활용할 수 있습니다. 자주 바뀌는 도구의 입출력을 파싱해 성공률을 시간에 따라 평가할 수 있고, 각 호출이 어느 계약에서 나왔는지 추측할 필요도 없습니다.
목록이 작아도 되려면 도구마다 다루는 범위가 넓어야 합니다. 그것도 서로 무관한 기능을 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 리소스, 서브에이전트 transcript, 스킬, 메모리, 로컬 스크래치 공간, omp 문서, SSH로 연결한 원격 머신까지 모두 같은 내부 URL 서브시스템에 들어갑니다. 이 설계를 추천합니다.
Read은 눈에 덜 띄는 복구도 처리합니다. 잘못된 절대 경로를 워크스페이스 내 고유한 접미사로 찾아 주고, Windows에서 ~을 확장하며, 턴을 낭비하게 만드는 그 밖의 경로 오류도 막아 줍니다.
이렇게 할 수도 있지 않을까요?
return await Bun.file(path).text();가능합니다. 하지만 그러면 익스텐션 작성자가 각자 리더를 구현하거나 모델이 셸로 우회로를 찾아야 합니다. 하네스는 web_fetch 같은 서로 다른 이름으로 비슷한 기능을 따로따로 노출하게 되고요.
그래도 복잡성은 사라지지 않습니다. 셸 명령, 프롬프트, 익스텐션, 실패한 도구 호출에 흩어질 뿐이고, 그러면 아무도 책임지지 않은 채 모두가 30%씩 조금씩 다르게 구현하게 됩니다.
Read이 복잡한 덕분에 읽기가 복잡하지 않게 됩니다.
Bash 도구는 단순히 Bash를 셸 호출로 넘겨서는 안 됩니다. 미친 소리처럼 들리겠지만 그렇습니다.
omp는 완전한 bash 파서와 인터프리터, 그리고 coreutils 전체를 프로세스 내부에 탑재합니다. 이유는 간단합니다.
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의 최근 연구도 같은 결론입니다. 다른 Claude가 명령을 읽는 auto 모드가 사람보다 훨씬 나은 결과를 냈습니다.
omp가 명령을 직접 해석하면 실행이 ln에 도달하는 순간에 승인을 요청할 수 있고, 그 앞은 모두 읽기 전용입니다. 사용자가 해당 디렉터리에 대한 쓰기를 이미 허용했다면 그 프롬프트마저 건너뛸 수 있습니다.
그러면 하네스는 "Bash"를 통째로 검문하는 공항 보안 검색대(TSA)에서 기능 단위 승인자로 바뀝니다. "Git으로 push해도 될까요?" 하고 묻는 식이죠. find, cat, ln 같은 일반 명령은 프로세스 내부에서 실행되며, 그 시점에 접근 모델을 조회하고 사용자의 기존 읽기/쓰기 정책을 그대로 따릅니다.
이 도구는 포크한 지 한 달쯤 되었을 때 추가했고, Anthropic이 비슷한 기능을 넣기 전이었습니다.
제품에서 사용자가 문제를 신고할 수 있는 창구를 어딘가에 마련해 두죠. 이것은 그와 같은 장치를 에이전트용으로 만든 것입니다. 에이전트가 도구의 어떤 점이 좋았는지, 무엇이 헷갈렸는지, 어떤 동작이 잘못되었는지를 완전히 자율적으로 수집할 수 있습니다.
리포트의 품질이 훌륭하다고는 못 하겠습니다. 예를 들어 Codex는 이름을 제대로 바꾸지 못하면 Read나 LSP 도구 탓을 하며 파일의 외부 수정에 대해 불평하기를 좋아합니다(제 잘못이 아니에요, TypeScript 쪽에 물어보세요). 하지만 이런 건 걸러 내기 쉽고, 걸러 내고 나면 어떤 도구가 어떻게 실패하는지, 어떻게 개선할지에 대한 신호를 엄청나게 얻을 수 있습니다.
ANSI 이스케이프가 섞인 문자열 배열과render()는 좋은 렌더링 프리미티브가 아닙니다.
사실 이 주제는 제가 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을 올리지 않는 한 여러분은 해당하지 않습니다)는 전달받은 추상 파이프라인 (&mut impl Out)에 RichText (Style, String)를 밀어 넣습니다.
덕분에 267초 걸리던 렌더링 시간이 90ms로 줄었습니다.

render(): string[] — N개 컴포넌트 × M개 변환, 모든 버퍼를 매 프레임 다시 파싱하고, 다시 측정하고, 다시 할당합니다. 이후: RichText 런이 추상 파이프라인을 단일 패스로 통과해 프레임 diff로 흘러갑니다.임시 객체, ANSI 파싱, 그래핌 처리는 프레임 렌더러 아래 모든 계층에서 완전히 사라집니다.
컴포넌트에 패딩을 붙여 아래로 넘길 이유가 뭘까요? 패딩을 스트리밍하고, 그다음 줄 하나를 스트리밍하고, 이를 반복하면 됩니다. 255줄짜리 diff를 풀 컬러로 렌더링한 다음 .slice(0, 3)로 잘라 또 다른 문자열 버퍼 배열로 만들 이유도 없습니다. 생략 부호 뒤에서 스트림을 끊거나, 변환 과정에서 직접 줄로 나누면 됩니다.
다음 단계로 string[]는 제대로 된 컴포넌트 모델로 대체됩니다. 상위 수준의 소비자는 박스를 쌓기만 하면 되고, LSP가 길을 안내해 줍니다.

<text> 안에 요소를 중첩하면 런타임에 프레임이 깨지는 대신 편집 시점에 린트 오류가 납니다.
<box>, <row>/<col>, <ico:new/> 아이콘, 가로 magenta..cyan 그라디언트, $$ \frac{1}{2} $$로 실시간 렌더링한 ½.저는 프런트엔드 작업을 좋아하지는 않지만, 좋은 추상화는 정말 좋아합니다. 레이아웃 엔진과 함께라면 (Element, Props, Children)만으로 충분하며, 이전과 비교하면 훨씬 근사해집니다.
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는 청크를 내보내는 주기가 매우 다릅니다. 한쪽은 단어 몇 개씩, 다른 쪽은 글자 몇 개씩 보냅니다. 이 차이를 매끄럽게 다듬으면 하네스가 얼마나 반응이 빠르게 느껴지는지가 달라집니다. 일정한 움직임은 진행 중으로 읽히지만, 몰아서 나오고 멈추기를 반복하면 그렇지 않습니다. 헤헤.
요즘 "메타"에서는 비용이 전혀 들지 않으면서 ROI가 가장 큰 투자가 있습니다. 인터랙티브 TUI/GUI에는 에이전트에게 디버그 프로토콜을 구현하게 하는 것입니다. "어떻게 검증하는가"를 모르고 명시하지도 않으면, 에이전트는 비슷해 보이는 우회로를 만듭니다. 대부분 아무것도 실제로 검사하지 않는 테스트 파일을 만든다는 뜻입니다.
"검증"이 무엇인지 정의하고 사용하기 편한 형태를 미리 갖춰 주면 마찰이 상당히 줄고, 검증이 개발 루프의 능동적인 일부가 됩니다.

형태 자체는 중요하지 않고 언제든 바꿀 수 있습니다. 커스텀 도구일 수도, Python 패키지일 수도, API일 수도 있습니다. 다만 비파괴적이고, 화면 밖에서 동작하며, 여러 인스턴스를 동시에 띄울 수 있는 무언가는 반드시 제공해야 합니다. 그래야 에이전트가 성공의 정의를 제멋대로 바꾸는(대개 낮추는) 일을 막을 수 있습니다.
TUI에서 정말 불가능한 일은 "고장 났다"는 GH 이슈를 0건으로 만드는 것입니다. 사람들은 모르는 분야에 대해서는 이상주의자가 되고, 안타깝게도 많은 이들이 자신이 원하는 완벽한 TUI 경험이 불가능하다는 것을 모릅니다. 모든 컴포넌트가 위치와 상관없이 항상 최신이고, 동적으로 변하는 TUI 말입니다.
표준 transcript는 블록의 리스트로 정의합니다. 블록은 텍스트 행을 만들어 내고 다음 생명주기를 거칩니다.
active → finalized → committed
살아 있는 동안 블록 i는 행의 배열인 현재 스냅샷 Wi를 보여 줍니다. finalize되면 변경할 수 없는 스냅샷 Fi로 고정됩니다.
블록에는 두 가지 모드가 있습니다.
이 구분은 블록이 자신에게 할당된 뷰포트를 넘어설 때 중요해집니다. mutable 스냅샷은 이후 업데이트가 대체할 수 있으므로 히스토리에 일찍 들어갈 수 없습니다. 그러면 이미 스크롤된 행을 되돌려야 하기 때문입니다. 반면 어시스턴트의 thinking 같은 append-only 블록은 안정적인 prefix만 늘려 가므로, 그 prefix는 즉시 커밋을 시작할 수 있습니다.
너비 W, 높이 H인 터미널에는 두 개의 버퍼가 있습니다.
기술적으로는 스크롤백을 지우고 덮어쓸 수도 있지만, 사용자들이 자주 불평하는 동작으로 이어지므로 이제는 불변 조건으로 둡니다.
줄바꿈 wrapW는 논리 행을 물리 행으로 바꾸며 현재 너비에 의존합니다. 뷰포트 아래에는 주소를 지정할 수 있는 영역이 없습니다. 맨 아래를 넘어 쓰면 터미널이 스크롤되면서 맨 위 행이 되돌릴 수 없이 S로 밀려납니다.
논리 히스토리 L은 줄바꿈하지 않은 행으로 유지하므로 너비와 무관합니다. 블록 순서대로 커밋된 final을 각각 정확히 한 번씩 담고, 현재 스트리밍 중인 블록 가운데 이미 내보낸 만큼을 더한 것입니다. c를 마지막으로 커밋된 블록, j = c+1이라 하면 다음과 같습니다.
L = F1 · F2 ⋯ Fc · Wj[1..ej]
여기서 ej는 스트리밍 중인 선두 블록이 이미 히스토리로 내보낸 행의 수입니다(블록 j가 스트리밍 중인 append-only 블록이 아니라면 ej = 0).
따라서 다음이 성립합니다.
리사이즈는 논리적으로는 아무것도 바꾸지 않습니다. 모든 Wi, 모든 Fi, c가 그대로 유지되고, 줄바꿈과 뷰포트 할당만 다시 계산합니다. 이미 네이티브 스크롤백에 들어간 행은 다시 쓸 수 없으므로, 이에 대한 정책을 명시적으로 하나 정해야 합니다.
그런데 왜 이런 "수학"까지 보여 드렸을까요? 이 알고리즘은 정합성을 검증하기가 무척 복잡하기 때문입니다. 이전 버전에서는 안정 상태에 이르기까지 퍼저를 작성해야 했는데, 이번에는 그 과정을 피하고 싶었습니다.
대신 설명한 대로 동작을 TLA+로 모델링하고, 명확히 명세한 불변 조건이 모두 충족될 때까지 블록의 커밋과 finalize 처리 방식을 반복해서 수정하도록 요청했습니다.
이제 부분 결과를 과감하게 커밋하거나 블록 잘라내기를 허용하지 않는 식으로 변경하고 싶을 때도 업데이트할 기준 모델이 있고, 그 변경이 통할지 아주 쉽게 알 수 있습니다. 실패하면 반례도 보여 줍니다.
논문과 ElasticSlots.tla 전체 소스는 부록 B에 있습니다.
자랑은 이쯤 하고 넘어가겠습니다. 이제 누가 TUI가 고장 났다고 불평하면, 왜 고칠 수 없는지 형식 증명을 건네 줄 수 있습니다. 훌륭하죠.

에이전트가 여러분이 유지보수하고 싶은 코드베이스 쪽으로 향하도록, 제약이 그 방향을 잡아 주는 언어를 고르세요.
프런트엔드 코드와 상호작용해야 하는 경우가 아니라면, 지금 TypeScript는 형편없는 선택입니다.
지금 프로젝트를 시작할 때 가장 영향이 큰 결정 중 하나는 알맞은 도구를 고르는 것입니다. 3년 전에 이런 문장으로 시작하는 글을 봤다면 저도 열을 냈겠지만, 못 믿겠다면 만들고 싶은 위젯을 설명하는 똑같은 프롬프트를 Claude에게 줘 보세요.
이제 macOS(Swift)를 Linux(Qt/JS)로 바꿔 보세요. 전자는 OS와 잘 어울리는 글래스모피즘 위젯을 줍니다. 후자는 UI 요소가 겹친 사각형과 의심스러운 UX 선택을 내놓아, UI를 정의하는 XML 스키마를 방금 다 읽고 처음 컴파일해 본 기분이 들게 합니다.
물론 프롬프트를 쓴 방식도 영향을 주고 더 자세히 쓸 수도 있었겠지만, 어떻게 해도 한쪽이 다른 쪽을 별 노력 없이 앞서는 것을 곧 알게 됩니다. macOS는 역사적으로 개발자가 일관된 디자인 스타일 하나를 따르게 만드는 데 강했는데, LLM에서도 똑같습니다.
Swift에는 자기만의 취향은 없지만 강한 기본값이 있습니다. 표준 라이브러리, 흔한 프로젝트 구조, 컴파일러 오류, 생태계 관례가 모두 생성 코드의 사전 분포로 작용합니다. 똑같이 평범한 스타일이 스무 가지나 되는 언어에서는, 모델이 여러분의 문제에 닿기도 전에 스무 번이나 스타일을 골라야 합니다.
안타깝게도 제가 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 } 중 무엇을 쓸까?저는 가장 대표적인 write-only 언어인 C++과 10년을 보냈기 때문에 이런 상황에서 나름의 재미를 느낍니다. 하지만 Zod와 Typebox 중에서 고르라고 강요받으면 주니어 친구는 이른바 isRecord를 그냥 만들어 버립니다. 타입을 union으로 묶으면 되는데 제네릭을 왜 쓰죠? 약간의 typeof로 특수화하면 되는데 두 타입 모두에서 모든 분기가 동작하는지 머릿속으로 왜 확인하죠? 클래스는 결국 객체와 프로토타입일 뿐인데 왜 쓰죠?
세상에 나쁜 JS 코드가 너무 많아서일 수도 있고, 학습 과정에서 minify된 코드 더미를 삼켰기 때문일 수도 있지만, 저는 이제 지칩니다. 같은 주니어가 Linux 제로데이도 찾아낸다는 점을 생각하면, 저라면 더 나은 모델이나 더 나은 코드 품질 도구를 바라며 온갖 편법을 쓰는 일은 그만두겠습니다.
EffectJS가 이 상황을 바꿀지도 모르겠습니다. 제 생각에는 결국 Go가 승자가 될 것입니다(특히 WASM용 GC 제안이 확정되면). Swift가 디자인에서 이기는 것과 비슷한 이유에서입니다(특히 컴파일 속도와 크로스 컴파일의 용이성). 다만 더 저수준의 시스템 언어가 필요한 경우도 있어서 여기서는 Rust를 택했습니다.
그래도 에이전트는 꽤 자주 방향을 잡아 줘야 합니다. 목표까지 최단 경로를 택하느라 복잡한 borrow를 다루는 대신 복사본을 할당하고, thiserror를 쓰지 않고 오류를 문자열로 넘기곤 합니다. 하지만 std와 serde 생태계를 조합해 동작하는 데 필요한 것은 대부분 갖추고 있고 컴파일러가 상당한 수준의 안전성도 제공하므로, 그렇게 결정했습니다.
다음 결정은 확장성을 위해 TS를 다시 부를지였고, 저희는 쓰지 않기로 했습니다. 주된 이유는 다음과 같습니다.
eval 도구가 별도 설정 없이 동작함을 보장할 수 있습니다. 사용자에게 py3 설치를 요청하고도, 저희가 제공하는 흐름에서 그것을 믿고 쓰지 못하는 상황을 피할 수 있습니다.@remote 설계가 가능한 이유입니다.에이전트 하네스는 시스템 소프트웨어입니다. fetch를 감싼 while 루프가 아닙니다.
그렇다면 왜일까요? 위의 모든 챕터가 사람들이 수십 년간 만들어 온 종류의 소프트웨어이기 때문입니다. 복제, 샌드박싱, 설정, 스케줄링, 프로토콜 호환성, 실시간 렌더링, 언어와 런타임 설계가 그렇습니다.
omp²는 이 문서를 기준으로 계속 만들어지고 있으며, 이미 출시된 부분도 있고 아직 구상 중인 부분도 있습니다. omp를 써 보고, 소프트웨어 공장을 돌리게 하거나 같은 휴대폰에서 스스로 카메라 앱을 만들게 하는 등 온갖 멋진 활용 방법을 공유해 주신 모든 분께 진심으로 감사드립니다.
여러분이 omp를 만들어 주셨고, 앞으로도 그에 못지않게 흥미로운 일이 이어질 것이라 기대합니다.
상태 챕터에서 다룬 실패 사례의 소스 링크, 최소 코드 발췌, 재현 영상입니다.
/fork이 체크포인트를 쓰기 전에 체크포인트가 지워집니다: git-checkpoint.ts소스: 내구성 있는 체크포인트 소유권이 빠져 있습니다. agent_settled이 stash ref의 유일한 맵을 이미 비운 뒤, idle 상태에서 /fork이 호출됩니다.
const checkpoints = new Map<string, string>();
// …
pi.on("agent_settled", async () => {
checkpoints.clear();
});plan-mode/index.ts소스: session_tree와 getBranch()가 빠져 있습니다. 되감아도 plan 모드와 그 도구 제한이 계속 활성 상태로 남고, 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로 표시되고, resume하면 다시 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복원; 사용자 수: 재구성이 도구 결과만 받아들이는데 사용자의 수는 custom entry입니다. X 이후, O 이전에 크래시가 나면 X가 사라집니다.
if (entry.type !== "message") continue;
if (msg.role !== "toolResult") continue;
// User moves take a different path:
pi.appendEntry(SAVE_TYPE, getBoardDetails());인터페이스 챕터의 transcript 불변 조건을 검증하는 데 쓴 논문과 전체 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)
====