Porting Ghostty to Rust

A technical account of porting the Ghostty terminal emulator from Zig to Rust with agents.

Rustty is a port of Ghostty from Zig to Rust, done over 112 days with Claude Code agents.

Try it in your browser

The two terminals below run one shell session in your browser, each drawn by a different terminal engine compiled to WebAssembly: Rustty's in the first and Ghostty's in the second. Type in either one. The shell's output goes to both, so you can compare what each engine draws.

Rust and Zig engines, side by side

Run the scripts in the demo folder and compare what the two engines draw. Try ls demo and ./demo/truecolor.sh.

Two interactive terminals in the page, side by side (stacked on a narrow screen). They show one bash-like shell running in your browser, drawn by Rustty's Rust engine in the first and Ghostty's Zig engine in the second, both compiled to WebAssembly; typing in either one reaches the shell, and both draw the result.

One shell running in your browser, drawn at the same time by Rustty's terminal engine and by Ghostty's, both compiled to WebAssembly. The shell is just-bash and the terminal front end is wterm, both Apache-licensed projects from Vercel Labs; the Rust engine speaks the same interface wterm already uses for Ghostty's Zig engine, so both can draw the same session. Nothing is sent to a server.

The front end is wterm, an Apache-licensed terminal for the web from Vercel Labs. wterm draws what a terminal engine hands it through a small interface, and one of the engines it ships, @wterm/ghostty, is Ghostty's Zig code compiled to WebAssembly. I had Claude write a second implementation of that interface on top of Rustty's engine, function for function, so wterm runs the Rust engine without a change to its own code. The two binaries are close in size, the Rust one slightly larger. The shell is just-bash, also from Vercel Labs: a bash interpreter written in TypeScript with an in-memory filesystem, so what you type stays in your browser.

The scripts in the demo folder exercise the engine on purpose: true colour, the underline styles, wide and combining characters, hyperlinks and window titles. One limit you can hit is images: wterm's Zig build can draw kitty graphics, but Rustty's engine leaves that feature out of its WebAssembly build, following Ghostty's own default for that target, so the Rust engine draws none. It is a known gap between the two engines on this page. I compared the two engines' terminal state on recorded sessions of real programs (ls, git log, vim, top and less), on hand-written edge cases and on seeded random byte streams. They were identical on every recorded session. Elsewhere they differ in a short list of cases, nearly all traced to wterm shipping an older Ghostty release than Rustty's reference commit. Rustty itself still has no web terminal app, as the limitations say; this one borrows wterm's.

At a glance

Ghostty is a terminal emulator written in Zig by Mitchell Hashimoto and its contributors. Rustty is a port of it to Rust: the terminal engine, the font and rendering stack, the app runtimes, the C API libraries and the build, with Ghostty's macOS Swift app running on top. Rustty's behaviour is defined by Ghostty's, so most of the credit for it belongs to Ghostty's authors.

The scope is narrow on purpose. Rustty aims for parity with one pinned Ghostty commit: everything Ghostty does at that commit, nothing it does not. The one exception is bugs. When the port finds a Ghostty bug, it fixes it instead of copying it.

Rustty ran on one Claude subscription that I shared with other projects, and the subscription's weekly limit set its pace. Agents committed on 59 of the 112 days. Without the limits, I estimate the same work would have taken about 46 days (41–51).

Unless a figure says otherwise, everything in this article was measured on 2026-10-10 at Rustty 58aa62a81, against Ghostty 246f70287, the reference commit at that time, on a MacBook Pro, Apple M4 Pro, 14 cores, 48 GB. Two kinds of figures are older. The Linux sanitizer runs are from the previous reference commit, a60e9e2a5, and their re-run at the current one is still to come. Figures drawn from the Claude Code and Codex session records (prompts, usage and cost) stop at 8 October, because later sessions include the research for this article and cannot be told apart from the port's own. Line counts are code lines as counted by tokei. The Zig side includes the Zig dependencies the port also covers: the libxev event loop, libvaxis, z2d and zf.

The port in numbers

Each tile is one headline figure of the port. Hover, tap or focus a tile for its source, its provenance and the commit or date it was measured at.

In 112 days, 302,922 lines of Zig became 508,388 lines of Rust across 3,085 commits. The Rust side has 7,287 tests across all crates, the ported dependencies included, against 4,433 Zig test blocks in Ghostty's src/. It beat Ghostty's release build on 14 of 14 benchmarks at the current reference commit and recorded 206 defects in Ghostty and its dependencies along the way.

fact table · git · tokei · test census · perf recordsAT A GLANCE

Tiles summarise the whole port, 2026-06-21 to 2026-10-10. Line counts are tokei code lines; test counts are Rust #[test] functions in every crate (including the ported libxev, libvaxis and z2d and the -sys wrappers) and Zig test blocks in Ghostty's src/ at the current reference commit; matched module by module with the dependencies, as in the translation lens, it is 7,221 against 4,911. Every value comes from the article's fact table; hover, tap or focus a tile for each figure's provenance and as-of commit. Cost and usage figures stop at 2026-10-08. The API-equivalent figure is list price for the same usage, a reference value, not money spent.

The card's test counts differ in scope: 7,287 Rust tests across every crate, including the ported libxev, libvaxis and z2d and the -sys wrappers, against 4,433 test blocks in Ghostty's src/ alone. Matched module by module, with the dependencies on both sides, it is 7,221 against 4,911, the comparison the translation lens uses. The stricter check is per module. In all 211 mapped Zig modules, the Rust test count is at or above the Zig one, and none falls below.

Why port, and what "port" means here

I went back through my prompt history to remember why I started this. Before that night there is no question about porting Ghostty, or about Zig. I was a Ghostty user and nothing more. The honest answer is that I wanted to see what was possible, and partly that it seemed like agents could do it. The first prompts were typed on the night of 20–21 June, Pacific time, in a directory that held no Rustty yet:

21:42 · "Checkout ghostty repo on github, in ~/projects."

22:40 · "…help me gauge any benefits (perf, rendering latency, memory, etc. others too) to doing a rust zig->rust port."

23:08 · "Assume I wanted to go ahead and do it, a full fidelity port, 100% functional parity, verified by all tests ported, every assertion, full coverage no exceptions…"

00:27 · "Lets call our port rustty…"

None of this was a plan yet. The third prompt is still hypothetical ("Assume I wanted to go ahead"), and the name came last. From that last prompt, Claude wrote a prompt kit: a "Parity Constitution", a master prompt and one prompt per phase. The kit pinned Ghostty 4749c4e93, Zig 0.15.2 and the baseline machine. One line in it set the priorities for the rest of the project: "Parity is the invariant; performance is the gradient". I launched the master prompt, and agents wrote the plan. The first commit landed 17 h 54 min after the clone prompt.

"Port" has a specific meaning here. It is a set of commitments, each one checked by a test or a gate:

  • Parity with one pinned commit. The reference started at 4749c4e93 and moved forward 8 times, to 246f70287. A change on Ghostty's main branch counts only after the reference moves past it and the port absorbs it. I call the porting that brings the port up to a new reference commit an upstream catch-up.
  • Nothing lost, comments included. The full-fidelity rule says: "Every comment is preserved — doc comments, inline comments, TODOs, license headers, ASCII diagrams, citations/spec references. […] Comments are NEVER dropped or summarized away." A fuzzy-matching check tracks 34,371 distinctive Ghostty comments and finds none missing, and 79 deliberately not carried, each excused by a written rule.
  • The same C libraries. FreeType, HarfBuzz and the rest are vendored and built from source, with no pure-Rust substitutes. Ghostty itself is never a build dependency. It is only a reference to compare against.
  • A byte-compatible C ABI. Rustty's terminal library ships the same header family as libghostty-vt: 35/35 headers and 208/208 functions. The embedding API, which the macOS app calls, maps 91/91 entry points one to one.
  • The Swift app is reused, not ported. On 12 July, Ghostty's macOS app was imported byte-identical: 35,733 lines, the same count as Ghostty's at the first reference commit. At first it linked through ghostty_* alias symbols. A later rebrand renamed it to call rustty_* directly. The work was the C ABI underneath it.
  • Parity is the whole scope. Two prompts drew the boundary. On 30 July: "Everything is in scope if ghostty supports it." On 28 September: "the aim is rustty parity with the exception of copying bugs".

The bug exception arrived late, on 11 September. Before that, the port copied Ghostty's bugs faithfully and noted them for upstream. The rule that replaced this begins: "Fidelity is owed to what the reference is for, not to its bugs." A later section tells that story. In short, 136 live defects in Ghostty and its dependencies are fixed in the port instead of reproduced, and 142 recorded defects are listed in a public log of deliberate differences from Ghostty.

The reference as a contract

No Rust was written on the first day until Ghostty had been turned into numbers the port would have to match. On 21 June, agents worked on the pinned checkout:

  • They ran Ghostty's full test suite and confirmed it passed.
  • They inventoried every Zig test block: 3,234 tests holding 11,975 assertions.
  • They counted 42,069 comment lines across the 512 files of src/.
  • They recorded 28 benchmark results and the binary sizes of Ghostty's release build as the performance baseline.
  • They built a byte-level differential harness and showed that it fails against an empty port, so a pass from it means something.

The port's rules state the idea plainly: "Every test {} block → a Rust test, assertion-for-assertion." The test count taken on the first day is a contract. The first audit then caught the contract itself being wrong. The first count missed the tests that Zig names with an identifier instead of a string, and two documents had frozen the total at 3,189. Under an exact-match reading, a complete port would have failed the gate. The phase's reviewer re-derived the total and blocked the plan until both documents said 3,234.

Gates went in on the first day too, and from the start they had to prove they could fail. In 10 of 12 of the first gate scripts, a self-test injects a violation and expects red: a crate cycle, a corrupted byte, or a crate marked done with zero tests. The rule behind them:

Never weaken, skip, xfail, or loosen a test or gate. A red gate that cannot be made green honestly → STOP and surface a precise blocker.

Memory-safety checking also started early. Miri became the primary undefined-behaviour and leak gate on day 1, in place of a custom tracking allocator. On day 3 I asked for AddressSanitizer and LeakSanitizer to complement it, and a sanitizer-matrix gate followed the same day.

The widget replays the contract filling up. Each square is one Zig test. It turns copper when its module's Rust test count reaches it. 86% of the first-day contract was covered within a week. The whole of it was covered on 30 July, one day before the reference moved for the first time. After that the target kept moving. Every move of the reference brought new Zig tests. The widget counts string-named and anonymous test blocks, the form its per-module mapping can match: 3,189 at the first reference commit (the contract's 3,234 also counts the identifier-named tests) and 4,433 at the current one.

The test contract

Each square is one Zig test, turning copper on the day its module's Rust test count reached it; play or scrub to watch the contract fill and grow. Below: Rust against Zig tests per module, the contract at each reference move and the census of skipped tests.

86% of the first-day contract was covered within a week. Each reference move grew the contract; in total the Zig suite grew from 3,189 to 4,433 string-named and anonymous test blocks.

git log · per-module #[test] countsZIG TESTS AS THE SPECdata

One square per Zig test, coloured by the day its module's Rust test count reached it. Counts from 110 daily snapshots of the Rustty repo against the Ghostty reference commit of the day. as of 58aa62a81

A count is a weak contract, and the widget shows where. When upstream added tests to a module that already had surplus Rust tests, the count was met before anything was ported. That happened to 616 of 1,049 newly added Zig tests. So a per-name checklist joined the count: every live Zig test must name its Rust counterpart or give a written reason. The census in the last panel previews a later failure, tests that passed while checking nothing, which the section on checks covers. At the current reference commit, the checklist stands at 4,287 of 4,287. It matches tests by name, so it covers the string-named Zig tests outside the build and benchmark code, a subset of the 4,433 blocks counted above. 7,287 Rust tests stand behind it.

Cutting a 345-file cycle into crates

Zig compiles Ghostty's src/ as one module, and its files import each other freely. At the first reference commit, the runtime imports among its 512 files formed a single strongly connected component of 345 files, with 1,410 import edges inside it. From any of those files you can follow imports and come back to it. The hub is easy to miss. quirks.zig, a small assert helper, is imported by 113 files in the cycle. One font workaround in it imports the font package, so the assert helper reached the font stack, and from there config, the app runtime and the renderer.

Cargo does not allow cycles between crates, so the port needed crate boundaries across which no import points upward. The crate-structure design written on day 1 listed 14 cuts. The rule for them was strict: "Cycle-breaking refactors […] are behavior-preserving structural changes ONLY". A cut may move code. It may not change logic, comments or tests. By mechanism, they break down like this:

  • Six traits. A lower crate defines an interface, and the upper crate implements it. For example, the renderer defines the inspector overlay it draws, and core depends on the app-runtime interface traits instead of the GTK or macOS backends.
  • Five moves. A file or type moves down into a lower crate. The backend enums went to a build-config crate, and grid sizes to shared render types.
  • Two splits. The global.zig hub became a leaf crate holding process-wide state, and the C API layer moved out of the terminal engine into a crate on top of it.
  • One injection. Config passes the values the font stack needs, so font never depends on config.

The widget tests each cut by removing it while keeping all the others. Every removal leaves a cycle in Cargo's terms, so every cut is load-bearing. After the planned cuts, a smaller knot of renderer, inspector, terminal I/O, core and crash files remained. It held together through one upward edge, from the crash reporter into the surface. One more seam cut it: a small trait local to the crash crate, which core's surface implements.

Cutting the cycle

The tabs step from the cycle among Ghostty's files, coloured by the crate tier each file became, through the planned cuts to the crate graph and a replay of each crate's growth. Click or press Enter on a file or crate for its details, or trace a real cycle file by file.

At the first reference commit, 345 of Ghostty's 512 source files formed a single import cycle with 1,410 edges inside it. The port broke it with 14 planned cuts, and every one is load-bearing: skip any one and Cargo sees a cycle. The result is 61 packages with a longest dependency chain of 18 hops.

Ghostty src/ at 4749c4e93 · cargo metadataFROM ONE IMPORT CYCLE TO A CRATE GRAPHdata

Stages 1 and 2: runtime @import edges among the 512 Zig files of Ghostty's src/ at the first reference commit (2026-06-20), excluding imports inside test blocks; the cycle is found with Tarjan's algorithm. Files are grouped by directory and coloured by the crate tier they became. Stage 3: the Cargo workspace graph, node area by code lines. Stage 4: daily Rust lines per crate from each crate's first commit. as of 58aa62a81

Three of the layering decisions shaped the code more than the others:

  • Font sits below the terminal engine. The font crate does pure parsing (OpenType, sprites, atlas, metrics). The terminal crate depends on it, never the reverse. Shaping, which needs the terminal's types, moved into a separate crate above both. This was decided on 25 June.
  • The renderer owns its view of the configuration. Config depends on the renderer, because a config change is sent to the renderer as a message. So the renderer cannot name a config type.
  • Process spawning lives in terminal I/O. The original plan put Ghostty's Command.zig in core, which created a new cycle. It moved to the terminal-I/O crate, which core already depends on.

The renderer's case shows the pattern. In Ghostty, the renderer reads fields straight off the config struct (src/renderer/generic.zig at 4749c4e93):

pub fn init(
    alloc_gpa: Allocator,
    config: *const configpkg.Config,
) !DerivedConfig {
    // …
    // Get our font styles
    var font_styles = font.CodepointResolver.StyleStatus.initFill(true);
    font_styles.set(.bold, config.@"font-style-bold" != .false);
    // …
    return .{
        .background_opacity = @max(0, @min(1, config.@"background-opacity")),
        .background_opacity_cells = config.@"background-opacity-cells",
        .font_thicken = config.@"font-thicken",

In Rustty, the renderer defines a trait with one accessor per config field, and rustty-config implements it for its Config. The body of init stays line-for-line comparable with the Zig (crates/rustty-renderer/src/config.rs and generic.rs at 58aa62a81):

pub trait RendererConfig {
    fn font_thicken(&self) -> bool;
    fn font_thicken_strength(&self) -> u8;
    fn background_opacity(&self) -> f64; // init clamps @max(0,@min(1,x))
    // …
    fn font_style_bold(&self) -> bool; // config.@"font-style-bold" != .false
    // …
}

pub fn init<C: RendererConfig>(
    alloc_gpa: Global,
    config: &C,
) -> Result<DerivedConfig, DerivedConfigError> {
    // …
    let mut font_styles = StyleStatus::init_fill(true);
    font_styles.set(Style::Bold, config.font_style_bold());
    // …
    Ok(DerivedConfig {
        background_opacity: config.background_opacity().clamp(0.0, 1.0),
        background_opacity_cells: config.background_opacity_cells(),
        font_thicken: config.font_thicken(),

On day 1, 29 crates were scaffolded from the plan, in leaves-first order. Porting followed that order only loosely. The terminal engine sits in the middle of the graph but went first, because everything above it needed it. Ghostty's own cycle kept growing during the port, to 403 files at the current reference commit, and the crate graph stayed acyclic through every reference move. Today the workspace has 61 packages and no cycle, and its longest dependency chain is 18 hops.

The first three weeks

The terminal engine went first, and fast. Between 21 and 25 June, rustty-vt grew from 6,996 to 135,431 lines, and from 113 to 2,032 tests. By 28 June the workspace had 3,005 tests, close to the size of the whole contract.

The upper tiers came next: config, font, terminal I/O, core, the embedded app runtime and the renderer. They ran into a problem the engine did not have. Ghostty's Surface.zig and App.zig call straight into subsystems that were not ported yet: the GPU renderer, the event loop and the inspector. Instead of waiting, the port gave each missing subsystem a real placeholder type whose methods panic with unimplemented!(). The code that calls those subsystems was ported in full, compiled and reviewed as real Rust. When a subsystem landed, the placeholder gave way to the real port. A dated audit lists every unimplemented!() that remains in the code, with its reason.

Each milestone ran as a Claude Code Workflow script. The usual shape was design, adversarial review of the design, port, then verify, with Miri in the verify step of most scripts. 155 of these scripts were written before 10 July. The human side was small and mostly about getting out of the way. In the 18 days from 22 June to 9 July I sent 116 prompts, and 418 commits landed, 3.6 per prompt. About a fifth of the typed ones began with "continue", "resume" or "retry", often after a usage limit. The first limit came on day 2: "The workflow stopped due to API throttling, please resume it". Others pushed the agents to decide for themselves:

06-25 · "Next time, after detailed tradeoff review, make a recommendation and just continue autonomously (no need to block on me)"

07-08 · "keep going, your call- stop stopping to ask me, youre supposed to progress autonomously."

On 8 July the plan settled on macOS first, with Ghostty's Swift app reused on top of a Rust C ABI. Metal drew its first pixels on day 19. On 12 July the Swift app was imported unchanged. Twenty minutes later, a commit recorded a runnable Rustty terminal on macOS: Ghostty's own app driving the Rust port, on day 22.

The widget shows every hour of the port: commits as cells, my prompts as dots. The prompts themselves, word for word, are in Every prompt. The first three weeks are at the top. The pattern that holds across the whole port is visible early. Agents kept committing overnight and for hours after the last prompt. Up to 8 October, 52% of non-merge commits landed more than an hour after my last prompt, and the hands-on prompting added up to about 79 hours.

Agents vs human: 111 days, hour by hour

Days run down and Pacific hours across; switch the cells between commits, lines changed and prompts, brush to zoom, or replay day by day. Agents committed most at 20:00, while the author's prompts peaked at 08:00.

52% of 2,718 non-merge commits landed more than an hour after the author's last prompt, and 9% more than eight hours after. Agents committed most at 20:00; the author prompted most at 08:00. About 79 hours of hands-on prompting drove the port, roughly 34 commits per hands-on hour, and the longest unattended run was 180 commits overnight.

git log --all · prompt history · usage transcriptsCOMMITS AND PROMPTS BY PACIFIC HOURdata

Each row is one Pacific day (06-20 to 10-08 14:00), each column one hour. Cells show non-merge commits on all branches (bots excluded), lines changed (vendored code excluded) or prompts, on a log scale; teal dots mark the author's prompts to the port, and hollow rings the 11 prompts of the founding sessions typed before the repository existed. Model is the Co-Authored-By trailer of each commit. API-equivalent cost is recovered per-call cost at list price for the port's sessions, a reference value, not money spent; hatched rows fall where Mac Claude transcripts were lost (06-21 to 09-07), so a blank there means not recorded, not zero. Threads are distinct agent threads in recovered transcripts. Lag is the time from the author's last port prompt to each commit; the 41 first-day commits followed an unrecorded launch prompt. Commit subjects in tooltips have internal task labels removed.

By 9 July the history was still linear, with 452 commits and no merges. The method was about to change. Two days before the macOS app first ran, the port adopted new rules and a new kind of audit, and the next section explains why.

The method matures

On 10 July, day 20 of the port, I paused the porting and asked for an audit of what had been built. Two days earlier, Jarred Sumner had published "Rewriting Bun in Rust", an account of Bun's own port from Zig to Rust. My prompt asked agents to read it, follow its links into the Bun repository and its deleted porting guide, and see what applied to Rustty. It also admitted something:

02:43 · "…note that I haven't validated my port at all […] and possibly catch bugs I haven't uncovered yet."

Three ideas in that write-up shaped what came next: a field-by-field ledger of who owns each pointer, written before pointer-heavy code is ported; a committed count of every unsafe site; and a list of the regression classes that had escaped Bun's tests. Rustty built its own version of each. A research workflow tagged each idea against the rules already in place, as adopt, adapt, already covered or reject. That afternoon six new rules were ratified. They covered explicit semantics for each Zig builtin replaced by a Rust primitive, compile-time work that stays compile-time, unsafe accounting, greppable porting markers, separate implementer and reviewer agents, and a verdict for every upstream commit.

The ledger came first. It has one row per pointer-typed field in Ghostty's pointer-heavy code: the file, struct and field, the Zig type, an ownership class (owned, shared, a borrowed field or parameter, an intrusive link, an FFI handle and so on), the Rust type an agent must use, and the evidence for the call. Its header tells agents how to use it:

Port agents consume this table VERBATIM: "trust it over local guessing". […] UNKNOWN => Option<NonNull<T>> + TODO(port).

It was filled retroactively for the 216 fields already ported, and from then on it applied in advance, under a new rule: "no pointer-heavy tier ports without its GHOSTTY_LIFETIMES.tsv rows landing first (2-reviewer consensus)." An audit of the existing code against it found 20 issues the same evening. The two medium-severity findings were latent aliasing undefined behaviour in the event loop's lock-free completion queue, in a branch no test reached yet. The fix two days later needed the queue in its own heap allocation. A one-line fix was not enough, and a small Miri model of the queue showed it.

The unsafe census made one committed search command the ground truth for the count. Next to it, a separate ledger tracks safe functions whose soundness rests on invariants the compiler does not check, which no grep for unsafe finds. The rule behind both sets a bar for calling such a function fixed:

"Fixed" bar: safe code cannot cause UB in a release build; debug assertions do not qualify; wrappers that hide the invariant do not qualify.

On 12 July, 8 regression detectors went into the check registry. Each one targets one class: byte slices reinterpreted as wider types, fixed buffers driven to capacity, constants folded into literals, compile-time work that drifted to run time (checked with golden bytes for every escape sequence the terminal emits), invariants that two crates must agree on, a sanitizer canary that must fail, unexplained copies, and comments that no longer describe the Rust behaviour. They entered as progress checks, with self-tests.

How the method changed

Each marker sits at the commit that changed how the port was run; hover, tap or focus one for the change. The era bands give the commit rate, and the strips beneath show the pointer-lifetime ledger and the check registry filling in.

On day 20 (07-10) the port adopted six new working rules and a pointer-lifetime ledger, which grew from 216 rows at creation to 314. An unsafe census started the same day and eight regression detectors two days later. On 09-11 the port added the rule to never carry a Ghostty bug into Rust; from then to 10-10 it averaged 45.9 commits a day, against 20.0 in the era before.

git log · method records · pointer-lifetime ledgerMETHOD CHANGES OVER TIMEdata
Working rulesVerificationLedgers and auditsOrchestrationUpstream, platforms
Founding rules23.8 commits a dayRules tightened20.0 commits a dayNever port a bug45.9 commits a daytriggerWorking rulesVerificationLedgers and auditsOrchestrationUpstream, platformsDefinition of parityMiri firstWorkflow scriptsSanitizer matrixRaw-pointer argumentsScripts kept in the repoMiri proof harnessesSix new rulesPointer-lifetime ledgerUnsafe censusFirst full engine Miri sweepAgent instructionsLinux and a Linux VMRegular upstream catch-upFalse greens auditedSecond opinion from CodexMove firstWorktree subagentsParity closure startsNever port a bugNo silent skipsComments not carriedWindows VM
rows in the pointer-lifetime ledger200250300JulAugSepOct314
entries in the check registry050100JulAugSepOct105
rows in the pointer-lifetime ledger200250300JulAugSepOct314
entries in the check registry050100JulAugSepOct105
  1. Founding rules · 06-21 to 07-09 · 23.8 commits a day
    1. 06-21Definition of parity. A written definition of parity with nine checks, and five rules: full fidelity, the rustty name, self-contained builds, an acyclic crate graph, and checks that are never weakened to pass. 15c1a9bd7
    2. 06-21Miri first. Miri adopted as the primary check for undefined behaviour and leaks; a custom tracking allocator was dropped. d9fd637ce
    3. 06-22Workflow scripts. Orchestration through workflow scripts: design, adversarial review, port, verify, one script per milestone with structured outputs.
    4. 06-23Sanitizer matrix. A sanitizer check (ASan, LSan, MSan, TSan, HWASan), a Miri progress harness and a written route for tests Miri cannot run. 161178d6a
    5. 06-24Raw-pointer arguments. Zig pointer arguments whose targets are re-read during a call are ported as raw pointers, after Miri caught a Stacked Borrows violation in screen clearing. 3ee9fd06d
    6. 07-02Placeholder leaves. Placeholder types with unimplemented leaves let most of the surface and app files port as live Rust before the GPU, event loop and inspector existed. 67579b58c
    7. 07-03Scripts kept in the repo. Workflow scripts committed to the repository as executable provenance (61 on that day). f7411131f
    8. 07-07Miri proof harnesses. Small isolated Miri harnesses prove aliasing questions instead of reasoning about them by hand; a Tree Borrows lane is added. f046d41a6
  2. Rules tightened · 07-10 to 09-10 · 20.0 commits a day
    1. 07-10Six new rules. Six new working rules: explicit semantics for replaced Zig primitives, compile-time stays compile-time, unsafe accounting, greppable port markers, separate implementer and reviewer, and tracking of every upstream commit. ccd14200e Trigger: Bun's write-up of its own Zig-to-Rust port was published on 07-08; ideas from it were adopted here on 07-10.
    2. 07-10Pointer-lifetime ledger. A retroactive ledger of who owns every pointer field (216 rows; its audit found 20 issues), and a checklist that requires ledger rows before any pointer-heavy part is ported. 8f6d781dc
    3. 07-10Unsafe census. A committed script that counts every unsafe site, and a ledger of safe wrappers that are not yet sound. d8ee18a75
    4. 07-11First full engine Miri sweep. Miri over all 2,037 terminal-engine tests found a 9-test aliasing bug in search; it was fixed the same day, about 7 hours later. 3277f2f78
    5. 07-12Eight regression detectors. Eight detectors for known classes of port regressions: byte-slice reinterpretation, fixed-buffer capacity, folded constants, compile-time phases and emitted bytes, cross-crate invariants, a sanitizer canary, injected copies and stale comments. 94ca0243e
    6. 07-15Agent instructions. A committed file of build, test and Miri commands for agents. 648d1afeb
    7. 07-16Branches and merges. Branch-and-merge integration starts; the history was linear before. d211a84a6
    8. 07-16Linux and a Linux VM. The Linux port starts, with a Linux virtual machine for GTK and OpenGL. 85d19023a
    9. 07-23Completion roadmap. Twenty workstreams run by executors in isolated worktrees, each behind a review step.
    10. 07-29Regular upstream catch-up. Moving the Ghostty reference commit forward becomes a recurring step: 8 moves absorbed 1,169 upstream non-merge commits. 3d79b3ba2
    11. 07-31False greens audited. A check that passed on bookkeeping alone is found; struck rows no longer vote, and a staleness check guards the unsafe census. aeb05fbdc
    12. 08-11Second opinion from Codex. Codex's read-only audit (659 registry rows) is vetted by Claude: 144 findings confirmed, 3 refuted, 140 fixed and verified. 8120dd10d
    13. 08-17Move first. Move the reference first, record each check's state before and after, then port the difference.
    14. 09-01Worktree subagents. Hand-made worktrees and agent workers replace the workflow scripts: 1,142 subagents in 214 worktrees in September.
    15. 09-08Parity closure starts. Work starts on closing the 497 findings of a functional parity audit, each with a failing test first and a negative control.
  3. Never port a bug · 09-11 to 10-10 · 45.9 commits a day
    1. 09-11Never port a bug. A Ghostty defect reachable from real input is fixed, tested with a negative control and recorded instead of ported; the defect ledger starts with 66 entries. 7dd991582
    2. 09-23No silent skips. The never-port-a-bug rule is extended to non-crash bugs that break evident intent, and a test may no longer skip silently. f94ab8e81
    3. 09-23At most three building worktrees. About seven parallel agents filled the disk, so at most three worktrees build at once and each deletes its build output after merging.
    4. 09-24Comments not carried. Every reference comment not carried into Rust is recorded with its reason. f1c1639b0
    5. 09-27Windows VM. A Windows 11 ARM64 virtual machine: the portable crates build and pass targeted tests on Windows. a8c4fea8f
    6. 09-28Parity closure merged. The parity closure is merged with one low-severity item open; the parity check is a hard check. 079cc21e7
    7. 10-01Memory-safety lanes redesigned. Miri runs one process per test, plus Tree Borrows, LeakSanitizer on macOS and self-tests for every sanitizer. 2d04310b0
    8. 10-07Seventh reference move. The reference moves to a60e9e2a5, with an escape-sequence differential test against Ghostty. 03709b897
    9. 10-09Full run on one machine. The check script gains per-gate timings, a parallel scheduler with a cap on heavy checks, change-scoped long checks, sharded Valgrind and per-check build directories, so a full run fits on one machine. 0182ccc35
    10. 10-09Eighth reference move. Ghostty moved again during verification: the reference moves to 246f70287 (18 upstream commits, among them a soft terminal reset) and every reading check is green again 74 minutes later. 2bf89b95a
    11. 10-10Final validation merged. The upstream catch-up is merged after a full validation at the current reference commit on macOS and the Linux VM, with the benchmarks re-run. 58aa62a81

Each marker is a dated change to how the port was run, placed at the commit that introduced it; hover, tap or focus it for the change and its commit. Era bands give commits per day. The ledger strip counts data rows in the pointer-lifetime ledger at each first-parent commit that changed it (last value per day); the registry strip counts check-registry entries at weekly snapshots.

The timeline puts these changes among the others, each at the commit that introduced it. The era bands make one point plainly. Over the two months after the realignment the commit rate fell only slightly, from 23.8 to 20.0 a day; what changed was what a commit had to show. The third band starts with the never-port-a-bug rule on 11 September, and that rule's section returns to it.

The first payoff came the night after the realignment, on 11 July. A Miri run over all 2,037 terminal-engine tests, the first full one, found undefined behaviour in 9 search tests, and it was fixed about 7 hours later. The ledger had no rows for the search code, so it could not have caught this one. Miri did. By 10 October the ledger had grown from 216 rows to 314, and the check registry from 12 entries on the day of the realignment to 105.

Zig pointers in Rust's aliasing model

Most of the memory-safety bugs the port introduced share one cause. Zig and Rust disagree about what a pointer promises.

In Zig, *T and []T are addresses. Two of them may point at the same memory, one may write while the other reads, and nothing in the language tracks which one came first. Rust references make promises the compiler relies on: a &mut T is the only live way to reach that T, and nothing writes through a &T while it is live. Breaking a promise is undefined behaviour by definition, even when the program seems to work, and that definition is what lets the optimizer assume the promise holds. Miri checks executions against two formal models of these rules. Stacked Borrows keeps a stack of tags for each memory location, one per reference or raw pointer derived from it. Writing through a tag pops every tag above it; reading through it pops only the unique tags (from &mut) above it. Using a tag that has been popped is undefined behaviour. Tree Borrows, the newer model, arranges tags in a tree whose nodes change state (reserved, active, frozen, disabled), and it accepts some patterns that Stacked Borrows rejects. The port checks every pure-Rust crate under Stacked Borrows and the pointer-heavy code under Tree Borrows as well.

The rule that trips up a direct translation is the protector. A reference passed as a function argument is protected for the whole call. Any access during the call that would pop or disable its tag is undefined behaviour on the spot, even if the function never uses the reference again. Ghostty's Zig often passes *Page, *Row or []Cell into a function that re-reads the same memory another way, for example an integrity assertion that walks the page list. Miri first caught this on 24 June in Screen::clear_cells. The agent's note from the fix states the asymmetry: "the Zig reference passes *Page / *Row / []Cell — Zig pointers/slices are UNPROTECTED — so it's sound there. The Rust &mut is a Rust-only over-constraint." The fix made those arguments raw pointers, with a short-lived &mut taken only for each mutation and dropped before any re-read. That became the rule. Today 135 public functions take raw-pointer arguments and carry a lint allowance that cites the Zig signature. They are safe functions whose soundness rests on callers passing valid pointers, which is exactly the class the ledger of unsound safe wrappers tracks. The first tab of the lens below shows the clear_cells pair.

Translation lens: Zig ↔ Rust

Pick a pattern to see Zig beside its Rust port, with lines mapped across, carried comments marked and the evidence for the Rust shape. Across the whole code base, the port has 2.56× Ghostty's comment lines for 1.68× its code lines.

Six Zig patterns and what they became in Rust: aliased pointer arguments, intrusive links, comptime parameters, packed structs, errdefer and byte slices. Overall the Rust code has 1.68× the Zig code lines, 2.56× the comment lines and 1.47× the tests. In the excerpts, 62 of 73 Zig comment lines were carried word for word.

git grep · Ghostty a60e9e2a5 · Rustty 1d22ba80dTRANSLATION LENS · ZIG ↔ RUSTdata

Excerpts are real code at Ghostty's reference commit and Rustty's head, side by side, with hand-drawn line mappings. Carried comments are found by exact text match. Occurrence counts are git grep over Ghostty's src/ (plus libxev where marked) and Rustty's crates/, tests included. The unsafe classification is a same-line heuristic and labelled as such.

The protector also applies to memory that is freed rather than read. Ghostty's startHyperlink passes its slices to startHyperlinkOnce, which explicitly allows them to point into the hyperlink it is about to replace (src/terminal/Screen.zig at a60e9e2a5):

fn startHyperlinkOnce(
    self: *Screen,
    source: hyperlink.Hyperlink,
) (Allocator.Error || Page.InsertHyperlinkError)!void {
    // Allocate our new Hyperlink entry in non-page memory. This
    // lets us quickly get access to URI, ID.
    const link = try self.alloc.create(hyperlink.Hyperlink);
    errdefer self.alloc.destroy(link);
    link.* = try source.dupe(self.alloc);
    errdefer link.deinit(self.alloc);

    // End any prior hyperlink only after duplicating the new value. The
    // source slices are allowed to reference our current hyperlink.
    self.endHyperlink();

The contract holds in Zig because a slice carries no promise. A Rust &[u8] argument does: dropping the allocation it points into, while the call is still running, is undefined behaviour even though the slice is never read again. A Miri sweep found this on 30 July. The port now keeps a safe entry point for callers that cannot alias, and an unsafe one with raw slice pointers that carries Zig's contract (crates/rustty-vt/src/screen/attrs.rs at 58aa62a81):

pub fn start_hyperlink(
    &mut self,
    uri: &[u8],
    id_: Option<&[u8]>,
) -> Result<(), pagelist::grow::IncreaseCapacityError> {
    // SAFETY: `uri`/`id_` are live readable slices for the whole call, and
    // per this function's documented restriction they do not alias the
    // current hyperlink state.
    unsafe { self.start_hyperlink_raw(uri as *const [u8], id_.map(|id| id as *const [u8])) }
}
// …
pub unsafe fn start_hyperlink_raw(
    &mut self,
    uri: *const [u8],
    id_: Option<*const [u8]>,
) -> Result<(), pagelist::grow::IncreaseCapacityError> {

A second difference is provenance: a Rust pointer may only access the memory it was derived from. Under Stacked Borrows, a pointer made from a reference to one field may access only that field. Tree Borrows is more permissive here, but the port must pass both. Ghostty's C API writes a style's eight boolean flags with one 8-byte store that starts at the first flag (src/terminal/c/style.zig at a60e9e2a5):

pub fn write(s: style.Style, out: *Style) void {
    // …
    // We have to align(1) because we're ptrCasting a u8 pointer.
    @as(*align(1) u64, @ptrCast(&out.bold)).* = bytes: {
        // …
        break :bytes @bitCast(bytes);
    };

Ported as &mut out.bold cast to *mut u64, under Stacked Borrows, the store reaches seven bytes outside its provenance. The fix on 27 September derives the pointer from the whole struct and adds the field offset (crates/rustty-vt-c/src/style.rs at 58aa62a81):

// SAFETY: the eight bool fields are consecutive starting at `bold`
// (const-asserted below) with unique 0/1 representations, so the
// unaligned 8-byte store writes exactly those fields. The pointer is
// derived from the whole `Style`, not from `&mut out.bold`: a
// one-byte field reference carries provenance for that byte only,
// and an 8-byte store through it is out of bounds (Stacked Borrows; […]).
unsafe {
    let base = out as *mut Style as *mut u8;
    (base.add(core::mem::offset_of!(Style, bold)) as *mut u64).write_unaligned(bytes)
};

The same arithmetic, run backwards, ports Zig's @fieldParentPtr (C's container_of): subtract the field's offset_of! from a raw pointer to the field to recover its parent. That is sound only when the field pointer carries the parent's provenance, for example one derived from the handle Box::into_raw returned, not from &mut parent.field. Intrusive lists use a per-type trait that returns the address of the link field, so the code never forms a &mut to an element that another pointer still references. The lens's second tab shows both.

The hardest cases are long-lived aliases. In the renderer's row loop, a run iterator holds a raw pointer to the font shaper inside the renderer for the whole loop, and the loop calls the shaper as it goes (src/renderer/generic.zig at a60e9e2a5):

var run_iter = self.font_shaper.runIterator(run_iter_opts);
var shaper_run: ?font.shape.TextRun = try run_iter.next(self.alloc);
// …
        // Otherwise we have to shape them.
        const new_cells = try self.font_shaper.shape(run);

In Rust, self.font_shaper.shape(run) creates a new reference to the shaper beside the iterator's pointer, a sibling. Under both models that pops or disables the iterator's tag, so its next step is undefined behaviour. The design reviewer flagged the hazard and proposed taking the whole renderer as a raw pointer. A Miri model showed that this was necessary but not sufficient: alone, it was still undefined behaviour under both models. The clean structure needed both halves. The shaper is called through the iterator's own pointer, a child of its tag, and the helpers called inside the loop take the renderer as a raw pointer (self_raw below) with narrow field access, instead of a whole &mut self (the fix; crates/rustty-renderer/src/generic/macos_impl/cells.rs at 58aa62a81):

let self_raw: *mut Renderer = self;
let mut run_iter = self.font_shaper.run_iterator(run_iter_opts);
let mut shaper_run: Option<shape::TextRun> = run_iter.next(self.alloc)?;
// …
            // Otherwise we have to shape them.
            // SAFETY ([…]): shape THROUGH the
            // hook's own raw `*mut Shaper` (`run_iter.hooks.
            // shaper`), a child reborrow that does NOT pop the
            // hook — a `self.font_shaper.shape(...)` sibling
            // reborrow WOULD pop it (Miri-proven UB). Same Shaper
            // object, byte-identical to the reference `self.
            // font_shaper.shape(run)`.
            let new_cells = unsafe { (*run_iter.hooks.shaper).shape(run) }?;

That case set the method. Much of the pointer-heavy code calls Metal, CoreText or C libraries that Miri cannot execute, so its tests cannot run under Miri. Instead, each aliasing question gets an isolated proof harness: a small crate with no FFI that reproduces the exact borrow shape, with candidate designs run under both models. The design that came out of the shaper fix says it directly: "do NOT hand-reason SB" (Stacked Borrows); test it under Miri instead. The repository now holds 33 of these harnesses. The next widget replays four of the port's bugs through the implementer, the reviewer, Miri's borrow stack and the fix.

Three reviewers: implementer, adversarial reviewer, Miri

Choose a bug and step through its life: the ported code, the reviewer's verdict, Miri's borrow stack at the bad access under either aliasing model, and the fix. The rail above places each bug among every recorded port memory-safety event, by the tool that found it.

Four real memory-safety bugs from the port, each a different aliasing mechanism, replayed through the code, the reviewer, Miri's borrow stack and the fix. Miri, or an audit that Miri then proved, found 62 of the 80 port memory-safety defects recorded up to 8 October. In one case the reviewer saw the hazard but proposed a cure that was still undefined behaviour; only the proof harness showed it.

git · Miri runs · review and audit recordsPORT MEMORY-SAFETY BUGS, REPLAYEDdata

Rail: the 37 curated events (80 defects) recorded up to 2026-10-08, test-isolation races excluded, laned by the tool that found each one. Code is shown at the commits named in each stage; where a first pass was never committed, the Miri proof-harness model is shown and labelled. Miri output was regenerated on 2026-10-08 by running the command in each terminal title against the parent commit of the fix (bugs A and C) or on the in-repo proof harness's first-pass model (bugs B and D), with nightly Miri of 2026-07-22, so tag numbers come from those runs. Reviewer text is quoted verbatim, with internal identifiers cut as […].

Memory-safety bugs the port introduced, and what caught them

Each bubble is one event, sized by its defects and laned by what caught it, with the working rules the bugs produced flagged along the timeline. Regroup the rows by crate area, filter from the crate × bug-class table, or select an event for its detail.

The port introduced at least 83 memory-safety defects in 39 events, and Miri caught 64 of them. 62 were aliasing or provenance undefined behaviour, a bug class that exists because Rust has an aliasing model and Zig does not. Sanitizers found 7: leaks, a dropped Zig field default and a C library initialisation race, none of them aliasing.

memory-safety timeline · git · port-findings ledgerPORT-INTRODUCED MEMORY-SAFETY BUGSdata

Plotted: 39 hand-curated events from commits and the port-findings ledger. Before September, port bugs were tracked only in commit messages and squashed merges, so this is a floor. Rows group the detecting tool: Miri over each crate's tests, isolated Miri proof harnesses, the Tree Borrows lane, sanitizers, review and symptoms. Bubble area is defects per event; the cumulative curve counts defects.

The record is a floor, because port bugs before September were tracked only in commit messages. It still shows the shape. The port introduced at least 83 memory-safety defects in 39 events, and Miri caught 64 of them, directly, through a harness or by proving an audit's finding. 62 were aliasing or provenance undefined behaviour, a class of bug that exists because Rust has an aliasing model and Zig does not. The sanitizers found 7, and none of those was aliasing. In October the Tree Borrows lane found two more bugs, both in tests: page tests passed &mut temporaries where the code takes raw row pointers, and a debug-only integrity read then froze the borrow before the next write, a sequence Stacked Borrows accepts. The tests now pass raw pointers, as Ghostty's do.

Performance

The kickoff rule put parity first and performance second, so the first head-to-head waited until 15 July. Rustty won none of the benchmarks against Ghostty's release build (0/14), and the suite was 42.0% slower. That night I asked for a worktree for the performance work and left:

00:47 · "Go autonomous from now, I won't be around to approve anything"

Between 01:18 and 10:41 on 16 July, agents landed 29 performance commits. At the end, Rustty won 14/14, with the suite −21.7%, the terminal parser 2.85× faster and screen cloning 2.42× faster. The replay steps through the night one measured build at a time.

From 0 of 14 to 14 of 14 in one day

Each dot is one benchmark's Rustty time over Ghostty's on a log scale, with its run-to-run range; step through the night one measured build at a time. Further down are the link-time-optimisation detour, the optimisations that measured worse, and memory and binary size.

On 07-15 Rustty lost all 14 benchmarks against Ghostty's release build in the same session, with the suite 42.0% slower. By the morning of 07-16, after 29 performance commits, it won 14 of 14: 2,269 ms against Ghostty's 2,897 ms, 21.7% faster. The terminal parser ended 2.85× faster and screen cloning 2.42× faster. At the current reference commit, on 10-10, it won 14 of 14 again, with the suite 27.4% faster.

perf log · raw benchmark sweeps · git logRUSTTY ÷ GHOSTTY, SAME SESSIONdata

Plotted: per-benchmark median of 7 timed runs (1 warm-up discarded) for each Rustty build, divided by the median of Ghostty's benchmark tool (Zig ReleaseFast) measured in the same session. Log ratio axis; bars span the run-to-run range. Win, tie or loss uses a ±1.5% tie band. Step times are commit times of the code measured. Hardware: MacBook Pro, Apple M4 Pro, 14 cores, 48 GB. as of 4749c4e93

These numbers hold only because of how they were measured:

  • The same benchmark on both sides. rustty-bench is a one-to-one port of Ghostty's benchmark CLI, and both read byte-identical input corpora. The Rust synthetic-data generator produces the same bytes as Ghostty's for the same seed.
  • Comparable builds. Ghostty's benchmark is built with -Doptimize=ReleaseFast. A build without that flag once gave misleading numbers. Rust uses a release-fast profile that differs from the shipped release profile in one way only: overflow checks are off, as in ReleaseFast. The shipped build keeps them on, to trap where Zig's ReleaseSafe traps.
  • Same-session Zig. Each figure is the median of seven (later nine) timed runs after one discarded warm-up, run one at a time, and Ghostty is re-measured in the same session as every Rust build. Across four sessions, the same unchanged Zig binary measured the terminal parser 11.8% apart. Session matters for Rust too: the same pre-fix Rust code measured +42.0% on the suite in the 15 July session and +14.9% in the 16 July session. So the starting gap overstates what the night's code changes closed. Part of it was the session.
  • Per-operation slopes, not wall clock. Every wall-clock figure includes process start-up. In August, an agent found that Rustty's process floor was about 3 ms lower than Ghostty's. On a benchmark that runs for a few milliseconds, that difference decides the result: the hyperlink lookup "won" by 15% on wall clock while running 7.5× slower per operation. Since then, short benchmarks run at several loop counts N, and the slope (t(N) − t(1)) / (N − 1) gives nanoseconds per operation.

Two results from that night shaped the rest of the code. Link-time optimisation helped the OSC parser and screen cloning by about 16% but slowed the terminal parser (322 → 531 ms), so it was reverted and banned in a comment above the profile in Cargo.toml. Without it, every hot public function called across crates needs #[inline], which Zig, compiling everything as one unit, never needed. And instruction-level profiling of screen cloning found a parity bug rather than a speed-up. Ghostty's break :hyperlink leaves only a labelled block, so the style handling after it still runs for that cell. The port had used continue, which skipped it, so a cell with both a hyperlink and a style lost its style on a same-page clone. The translation compiled and passed every existing test.

The August round widened the matrix to newer benchmarks, measured against Ghostty's main branch, and found the main source of performance gaps: Zig compile-time parameters and constants that the port had turned into runtime values. A compile-time width parameter that became a runtime enum, for example, turned a fixed-size store into a memcpy call per cell. Fixing those took snapshot encoding from +295% to +16% relative to Zig and left Rustty faster on 23 of 33 configurations. In the same round, swapping hot assert! calls for debug_assert! made the code slower. In a release build assert! still checks its condition, and the optimizer can use that condition afterwards; debug_assert! removes both the check and that knowledge. Some of those asserts were what removed later bounds checks.

On 7 October, at 12752b2ac, two reference commits before the current one, and on an idle machine, Rustty won 14/14 again in two independent runs, with the suite −27.9%. The workloads had changed with the reference, so those ratios are not comparable with July's. At the current reference commit, 246f70287, the suite ran again on 10 October with the same harness, and Rustty won 14/14 with the suite −27.4%. The terminal parser ran in 23% of Ghostty's time and the OSC parser in 30%. The closest benchmark, terminal-stream, lost its first nine-run round by 1.3% and won the harness's built-in eighteen-run re-run by 2.1%; an independent run of the performance check gave a suite −27.7%.

Keeping up with upstream

Ghostty kept moving during the port. Between the first reference commit and the current one, upstream landed 1,169 non-merge commits (+185,622 / −33,862 lines, summed over the moves), and Ghostty's Zig code grew by +34.0%. The port moved its reference 8 times to keep up. One rule governs every move:

updating the reference checkout does not port anything — "a merge does NOT port the fix; only an explicit .rs change does".

The first move ported the upstream range first and moved the reference at the end. 47.6 hours passed between the new reference commit landing upstream and the port adopting it. From the second move on 17 August, at my direction, the order was reversed. The port moves the reference first, runs every check against it, records which checks went red, and then ports until they are green again. The red counts became the progress meter. At the second move, 1,230 reference comments had no Rust counterpart and 36 Zig tests had none. At the third, the counts were 1,536 comments and 295 tests, 202 of them in the kitty module, for new clipboard, drag-and-drop and graphics-animation features. From then on, each catch-up ended when the counts were back at zero.

The wait to adopt a new reference commit dropped to as little as 1.7 hours at the third move. It was not a steady decline: the last move waited 7.7 hours, and the median was 7.55 hours. Catch-ups also got shorter, but not steadily. The second took 7.6 days from move to finish, the seventh 9.7 hours and the eighth 74 minutes, though part of the eighth's porting merged just before its move.

Chasing a moving target

The size chart sets Ghostty's growth against the port's, with each reference move marked, and the matrix shows where each move's upstream range landed, as lines changed or commits. Select a move to link the chart, the matrix and its detail panel.

The port moved its Ghostty reference commit 8 times, absorbing 1,169 upstream non-merge commits while Ghostty's Zig code grew 34.0%. The wait between an upstream commit and the port adopting it was 47.6 hours at the first move, as little as 1.7 hours, and 7.7 hours at the last; the median was 7.55 hours. 43% of the upstream churn landed in the terminal engine.

Ghostty git · upstream catch-up recordsUPSTREAM ABSORBED PER REFERENCE MOVEdata

Plotted: Ghostty .zig line counts at the first-parent commit of each week and at each reference commit; Rustty .rs line counts per day (raw lines, not code lines, so the two are only roughly comparable). Matrix: end-to-end diff of each reference range grouped by top-level directory, and non-merge commits touching each directory. Wait is the upstream commit time of the new reference to the time of Rustty's commit that adopted it.

The matrix shows where the work landed. 43% of the upstream churn was in the terminal engine. One move also exposed a weakness in the checks themselves. At the fifth move, on 1 September, the per-module test-count check stayed green, but only because two new upstream files were outside the list it read. 23 new Zig tests were invisible to it. The next section is about that kind of green.

The seventh move, on 7 October, arrived with 411 reference comments and 73 Zig test blocks missing. The catch-up closed 9.7 hours later with both counts back at zero. Ghostty moved again while that catch-up was being verified: the eighth move, on 9 October, brought 12 upstream non-merge commits, among them a soft terminal reset, and arrived with 19 reference comments and 8 Zig test blocks missing. The comment, test and symbol checks were green again 74 minutes after the move, and porting that range recorded one more Ghostty defect, an origin-mode cursor outside the margins that the terminal formatter replays at the wrong position.

Checks as the specification

The check registry is where "parity" is written down precisely enough to fail. It had 11 entries on the first day and has 105 today. 79 of them must pass. The other 26 track progress toward a target while the code they measure is unfinished, and they start failing once a crate claims to be done. Every check also has to prove it can fail. 27 registry entries are self-tests that inject a violation into a check and require it to go red, and 58 of 93 check scripts carry a self-test of their own.

How the checks grew

Step lines trace the registry week by week, with its self-tests and the check scripts that can test themselves. In the waffle, filled squares must pass and outlined ones track progress toward a target.

The check registry grew from 11 entries to 105: 79 hard and 26 progress checks, including 27 self-tests. At the end, 34,371 Zig comments were carried into the Rust code with none missing. The C API matched 35 of 35 headers and 208 of 208 functions, and the embedding API 91 of 91 entry points.

check registry · comment and C API censusCHECK REGISTRY GROWTHdata
registry entriesself-tests in the registrycheck scriptsscripts with a self-test
entries in the check registry0255075100JulAugSepOct10527
check scripts, and how many test themselves050100JulAugSepOct9358
entries in the check registry0255075100JulAugSepOct10527
check scripts, and how many test themselves050100JulAugSepOct9358
The registry today: 105 entries, 79 must pass, 26 tracked

Filled = must pass; outlined = tracked toward a target.

34,371 distinctive Zig comments carried into Rust, 0 missing

29 crates. A distinctive comment is a phrase or sentence of five or more words; a further 79 comments are recorded as not carried, each with its reason.

Step lines count entries in the port's check registry at weekly snapshots, with the self-tests among them, and the check scripts that can test themselves. The waffle shows today's registry by kind: filled entries must pass, outlined ones are tracked toward a target. A distinctive comment is a phrase or sentence of five or more words in Ghostty's source; the C interface pills compare Rustty's headers and exports with Ghostty's at the reference commit.

The hardest lesson was about checks that pass without checking anything. On 30 July, the check that guarded the close of the main porting phase was green, and had been for days. It took its list of open work from the roadmap, a hand-maintained checklist. Ticking off three finished workstreams turned it red. Its green had depended on rows staying nominally open. Two faults pointed in opposite directions. Four roadmap rows contradicted commits that were already merged, and a struck-through row still counted as open, so work it owned passed on the strength of a retracted line. The note the agents wrote down afterwards states the lesson: "a green result proves nothing until you check what the green rests on". Since then, a check that derives its verdict from a document asserts that its derived sets do not overlap, and its self-test breaks each requirement on the real rows and demands a rejection. It also fails loudly when no row exercises a requirement, and it prints every accepted exception on every green run.

The same failure came back in other forms. One was the test-count check that missed new upstream files. Another was the silent skip. A test that cannot find a resource it needs, such as a font or a display, and returns early reports ok. On 23 September a new check found 185 such sites. The rule written that day:

A Rust test that returns early reports ok, which cannot be told apart from a pass.

Now a test that cannot always run is either compiled out by cfg, marked ignored, or calls a helper that turns its skip into a failure on lanes that have the resource. When last counted, at 1d22ba80d, 6,325 of 7,270 tests had no skip mechanism at all.

One check exists because of a crash. On 15 July, every dynamically populated macOS menu shortcut was blank. The cause was a struct returned by value across the C interface. TriggerC embedded a modifier type without a C layout, which made it 20 bytes in Rust against 12 in the header. On arm64, a returned struct larger than 16 bytes travels through a hidden pointer, and a smaller one comes back in registers. So Swift and Rust used different calling conventions for the same function. The fix commit records a SIGBUS on the first call, and the blank shortcuts in the app. A lint allowance had hidden the compiler's warning. The check added that day compiles a probe with clang and requires every type returned by value through the embedding header to carry a Rust size assertion that matches it.

The checks that define parity go past counts where they can:

  • Comments. A first check compared comment line counts per crate, and a dropped comment slipped past it. Its replacement matches content: every distinctive Ghostty comment, a phrase of five words or more, must have a fuzzy match in the port's comments after the rename. At the current reference commit, 34,371 comments match and none is missing. 79 are deliberately not carried, and each of those rows has to prove its reason mechanically.
  • Tests. In each of the 211 mapped modules, the Rust test count is at or above Zig's.
  • Escape sequences. A later check runs the esctest2 suite against both terminal libraries and requires the same outcome and log for every test: 567 tests, with identical results on both sides.
  • The C interface. Rustty's terminal library matches Ghostty's on 35/35 headers, 208/208 functions and 79/79 enum sentinels that force C compilers to size enums as int. The embedding API maps 91/91 entry points.

All of these checks were green when the latest catch-up closed at the current reference commit, and in the full validation run that followed it.

Never port a bug

Until 11 September, the port copied Ghostty's bugs. When a translation reached a crash in the reference, the agent ported the crash, marked it as deliberate fidelity and recorded it for a later upstream report (one example). The rule changed over one GTK file. That morning a parity task ported how Ghostty reads the reply of the desktop portal's global-shortcuts request. If the reply body has an unexpected shape, params_.get leaves vardict null while response stays 0 (src/apprt/gtk/class/global_shortcuts.zig at a60e9e2a5):

var response: u32 = 0;
var vardict: ?*glib.Variant = null;
defer if (vardict) |v| v.unref();
params_.get("(u@a{sv})", &response, &vardict);

switch (response) {
    0 => {
        log.debug("request successful", .{});
        method.onResponse(self_cb, vardict.?);
    },
    1 => log.debug("request was cancelled by user", .{}),
    2 => log.warn("request ended unexpectedly", .{}),
    else => log.warn("unrecognized response code={}", .{response}),
}

Unwrapping a null optional with .? panics in a safety-checked build and is undefined behaviour in ReleaseFast. The signal subscription has no sender filter, so any process on the session bus can send such a body. The port reproduced the panic on purpose, with a note saying so. Half an hour after that commit, I wrote:

10:43 · "Wait, that seems odd. We shouldn't port the crash, please don't. Fix it here, update the plan/rules accordingly, and document all issues like this so we can propose upstream fixes later. Do this for past work too…"

Four minutes later the rule was in the rules file, and a minute after that the panic was gone. A body without results now takes the reference's own "ended unexpectedly" branch (crates/rustty-apprt/src/gtk/class/global_shortcuts.rs at 58aa62a81):

let (response, vardict) = response_params(params);

// PORT NOTE: reference defect […]. A deliberate divergence […], not an oversight: a body that is
// not `(ua{sv})` leaves `response` 0 and `vardict` null, and the reference then
// runs `method.onResponse(self_cb, vardict.?)` — a panic in a safe build, undefined
// behaviour in ReleaseFast. It is reachable from ANY session-bus peer: this
// subscription, like the reference's (`:481-491`), has a null sender. […]
// code 0 without a vardict takes the `2` arm — the
// failed response it evidently is, with the reference's own "ended unexpectedly"
// warning — and `onResponse` is not called.
match (response, vardict) {
    (0, Some(vardict)) => {
        log::debug!(target: LOG_SCOPE, "request successful");
        self.on_response(method, &vardict);
    }
    // …
    (2, _) | (0, None) => log::warn!(target: LOG_SCOPE, "request ended unexpectedly"),

In plain words, the rule is this. A crash, undefined behaviour, leak or deadlock in Ghostty that real input can reach is not reproduced. The port implements what the reference evidently intends. A test proves the fix and must fail when the bug is put back. A port note marks the site, and the defect goes into a ledger with a proposed upstream fix. Assertions of real invariants still port as assertions. The rule also says how to decide:

When unsure, trace where the input comes from before deciding; "the reference also traps" is not by itself a reason to trap.

On 23 September the rule was extended to bugs that crash nothing but break the code's evident intent, such as output lost to a missing flush. Since then "the port implements the intended behaviour, not the bug", and every such difference also gets a line in a user-facing list of fixes.

The third band on the strategy timeline starts with this rule. From 11 September to 10 October, commits ran at 45.9 a day, about twice the earlier rate. The closing work of that period came as many small commits, one finding each, with its test first.

The ledger held 66 entries at the end of 11 September, most of them from a sweep of earlier work, so those dates record when an entry was written, not when the bug was found. It now holds 206: 173 in Ghostty and 33 in its dependencies, mostly libvaxis and libxev. 136 are live: 60 crashes, 19 cases of undefined behaviour, 14 leaks, 9 hangs or deadlocks, and 34 that break intent. The rest are latent (59, unreachable at the reference commit) or weaknesses in Ghostty's own tests (11).

Defects on the map of Ghostty

Ghostty's source tree as a map sized by Zig lines, with one mark per defect at its file: colour gives the class and shape how it was found. Filter by either, and click a mark for the defect, its ReleaseFast consequence and the Rustty commit.

Porting surfaced 206 defects: 173 in Ghostty and 33 in its dependencies. Of the 60 crash-class entries, 37 are undefined behaviour (estimated) in Ghostty's ReleaseFast build. Checked arithmetic found 29 crashes, and reading and review found the most defects overall (93). Upstream had already fixed 4 independently, confirming they are real: 3 in Ghostty and 1 in Highway, not yet in Ghostty's pinned version.

upstream-defect ledger · Ghostty 246f70287UPSTREAM DEFECTS FOUND BY PORTING

Plotted: every entry of the port's upstream-defect ledger (206), placed at the first file path in its reference. Tile area is Zig lines at Ghostty's reference commit; children under 3% of their directory fold into other files, and tiles holding a defect get a minimum area so their marks fit. Dependency defects and non-Zig build files sit in the strip below the map, not to scale. Class comes from each entry's kind (a mixed leak/crash entry counts as a crash). Colour and the class chips give the class (hollow: latent; dashed: test weakness; centre dot: hang); mark shape gives the discovery method. Discovery method (a hand pass over the ledger) and the ReleaseFast consequence (read from each entry's ReleaseFast field) are estimated classifications. Commit = the Rustty commit that recorded the entry, normally the one that shipped the fix. as of 58aa62a81

A crash in a safety-checked build understates the problem, because Ghostty ships ReleaseFast, where those checks are compiled out. Each ledger entry states what happens in ReleaseFast. Read by hand, 37 of 60 crash entries become undefined behaviour there, 13 silently produce a wrong value, and 10 still terminate.

The ledger also shows how porting finds bugs. The method of discovery is a hand classification, so these counts are estimates:

  • Reading and review found the most, 93, during parity audits, defect sweeps, translation and reviewer passes.
  • Checked conversions found 30, and 29 of them are crashes. A Zig @intCast or an overflow on input-controlled data has to become try_from or checked arithmetic in Rust, and choosing between them forces someone to work out the range.
  • Compiling code Zig never analyses found 17, none of them a crash. Zig type-checks lazily, so code that no build reaches can rot unnoticed. Rust compiles all of it.
  • Bringing up other platforms found 25 (6 crashes), fault injection 12, porting Ghostty's tests 11 and the sanitizers 3.
  • The rest: the type and borrow checker found 5, porting the build system 4 and live use of the port 4. The two crashes still to be reported privately were not classified by method.

Miri and fuzzing found none first. Zig has no aliasing model for Miri to check. Miri can only confirm a Rust model of an access pattern that was found some other way.

None of the defects has been reported upstream yet. Before any report, upstream had fixed 4 independently, three in Ghostty and one in Google Highway (not yet in Ghostty's pinned version), and partly fixed 5. That is independent evidence that the entries are real. Two crashes reachable from ordinary terminal output are to be reported privately, and one entry is obsolete upstream. The remaining 194 are unreported, each with a proposed fix ready to file, and 142 appear in the public list of fixes.

Second opinions

Every reviewer in the port so far was a Claude Code agent reviewing another Claude Code agent's work. At the end of July I wanted a review from a different vendor, so I ran Codex in the Codex app. Its instructions were to read both repositories, write only into one findings directory, change nothing, and check its own claims:

"I want you to report on things that you've actually verified. If you come up with a theory or flags, I want you to come up with a way to vet and verify it (do adverserial reviews of your own review comments)."

It ran twice, for about 43 hours between 31 July and 2 August (UTC). The first run began as an audit of non-macOS parity, split into workstreams, and returned 20 verified gaps. During it I widened the goal to every line of code, and the second run restarted with that goal. Codex listed 3,103,589 lines across both repositories and split the files into hash-pinned line ranges. It gave each range to a primary auditor and had a second agent independently replay every non-trivial result. Codex's goal loop kept itself going, with 203 automatic continuations against 10 messages from me. It spawned 667 workers, at most 6 at once. Its weekly usage meter went from 8% to 99% over the second run, and the audit stopped there. By then its strict ledger credited only 0.35% of the lines as mechanically verified, though the primary reviews had covered much more ground less formally. The output was a registry of 659 rows.

Eight days later, Claude Code agents took the registry as input. Rows that shared a root cause were folded into 147 groups. Each group went to its own read-only agent, which had to confirm or refute it against the current code without trusting the report's claims. Each fix batch then went to a fix agent followed by an independent verify agent. Everything landed in one commit: 532 files, 336 new proving tests and 4,660 tests passing.

Most of what Codex found was what a macOS-first plan had never exercised. Windows did not compile, with 25 errors cutting off the terminal engine, its C library and terminal I/O. FreeBSD selected a Linux-only GTK path and a macOS-only kqueue backend. Kitty graphics had never been rendered on Linux, and custom shaders were never applied. One finding paid off indirectly. It noted that the port had no coverage-guided fuzz target. The target added in response found a reachable panic: RGB::parse, called from the live OSC parser, sliced a Rust string at a byte that was not a character boundary, where Ghostty slices bytes and returns InvalidFormat. Parsing moved to &[u8].

The same separation of roles runs through the port's own work. The rule for it:

Split contexts: implementer, verifier, and fixer are separate agents; the verifier works a concrete violation checklist […] and defaults to not-ok-if-uncertain; the fixer makes surgical edits only and may skip a hallucinated finding with a note

Separately, the recovered transcripts hold 1,073 automated security-review sessions, most of them on Opus 4.7 at extra-high effort.

A second opinion from Codex

The funnel narrows Codex's raw rows to findings that were confirmed, fixed and verified. The meter beneath tracks its weekly usage limit hour by hour across both runs, with the audit's events marked; hover, tap or focus a reading for its value.

Codex audited the port read-only over about 43 hours in two runs. Its 659 registry rows reduced to 147 distinct findings: 144 confirmed and 3 refuted, and 140 were remediated and verified. The weekly usage meter went from 8% to 99% during the second run.

audit registry · Codex rate-limit log · gitCODEX AUDIT FUNNELdata
The 147 distinct findings by Codex's severity

P1 is the most severe, P4 the least. Claude agreed with Codex's severity on 144 of 147.

Fixed in one remediation commit, 8120dd10d (2026-08-11): 532 files, 4,660 tests passing, 336 new tests that prove a fix.

Codex weekly limit used during the audit: median reading per hour (UTC), band = every reading in the hour
run 1run 20%25%50%75%100%08-0108-02run 1 startsrun 1 ends · 20 gapsrun 2 starts“use medium effort”100th round659-row registrylast call · 99%readings to 16%, then 1–6%run 1's log ends at 6%1%99%
run 1run 20%25%50%75%100%08-0108-02run 1 startsrun 1 ends · 20 gapsrun 2 starts“use medium effort”100th round659-row registrylast call · 99%readings to 16%, then 1–6%run 1's log ends at 6%1%99%

Funnel stages count audit findings from the registry rows Codex wrote to the fixes verified in the remediation commit; the first count is as Codex reported it, the rest are counted from the vetting state. The meter is the median of the weekly-limit readings Codex logged in each hour (UTC) of its two audit runs, 07-31 to 08-02, with the band spanning every reading in the hour and the audit's events marked; the readings of 08-01 00:00 to 03:59 are unexplained and annotated.

Precision is what matters in a second opinion. Of the 147 groups, 144 held up under independent checking and 3 were refuted. Claude agreed with Codex's severity on 144 of 147, and 140 were fixed and verified.

The verification matrix

The tool for each kind of code was set early: Miri for pure-Rust crates, AddressSanitizer for crates that call into C, and Ghostty's own Valgrind job, reproduced. The lanes were added one by one. The first time every lane ran to completion was at 12752b2ac, two reference commits before the current one, between late September and 7 October.

On 1 October I questioned the cost of all this:

21:26 · "lets re-evaluate the value of these long running miri checks. do most rust projects even run with them?"

The design written that day kept Miri and changed how it runs. At the fifth reference move, the Miri lane had not finished in about 30 hours. Miri interprets the program, and libtest ran all of a crate's tests in one process. Running each test in its own process with nextest, several at a time, cut three groups of slow tests from 11,722 s to 1,687 s, 6.9× faster. At 12752b2ac, Stacked Borrows covered 38 crates in 13,354 s, and it covered the same crates again at the current reference commit. A second lane runs, with debug assertions off, the tests that are only practical under Miri that way, and at the current reference commit it passed 126 of 126. Tree Borrows covered the pointer-heavy code, and at the current reference commit also the 69 tests of the app core. The design also widened the sanitizer lanes around Miri. Each lane's latest complete run was at the current reference commit on macOS and for Valgrind, and at the previous one, a60e9e2a5, for the Linux sanitizers:

Every lane at every reference commit

Pick a reference commit to see every host and tool lane at it, then a cell for its run; a blank cell means no recorded run, not a failure. The lane history below follows each lane across reference commits.

Taking each lane at the latest reference commit it completed at, 58,767 test executions ran under a sanitizer or Valgrind on macOS and a Linux VM: the macOS lanes and Valgrind at the current reference commit, the Linux sanitizers at the one before. Miri was the matrix's bug finder: Stacked Borrows lanes found 18 port bugs and Tree Borrows 15, against 10 for all sanitizer and test lanes combined. The matrix was first complete at the sixth of eight reference moves.

port records · verification logs · Linux VM runsHOSTS × TOOLS × REFERENCE COMMITSdata

Each cell is a recorded run: test count, wall time and date as written in the port's records, or the commit that recorded it. A blank cell means no run is recorded for that reference commit, not that the lane failed. "Not available" means the tool does not exist for that host (MemorySanitizer on macOS, HWAddressSanitizer outside aarch64 Linux). as of 246f70287 / a60e9e2a5

The matrix settles the question I asked. For the port's own bugs, unlike Ghostty's, Miri was the bug finder. Recorded per run, the Miri lanes found 41 port bugs: 18 under Stacked Borrows, 15 under Tree Borrows (all in test code) and 8 in the C API lane, against 10 for all sanitizer and test lanes combined. The sanitizers found what Miri cannot run: 3 defects on the upstream side, in C, C++ and FFI paths.

  • Valgrind's first memcheck run found that Ghostty leaks a font collection when the shared glyph grid fails to initialize.
  • LeakSanitizer's first macOS run found that Dear ImGui's Metal backend, built without automatic reference counting, leaks every frame.
  • ThreadSanitizer found a data race in Google Highway. Two threads making their first SIMD call at the same time read and write its chosen-target mask without synchronization. The fix uses relaxed atomics in the vendored header.

At the previous reference commit, a60e9e2a5, every lane completed except Tree Borrows on the app core, which did not finish there. ThreadSanitizer on the Linux VM found a link regex compiled on a test thread before the Oniguruma library's one thread-safe initialisation, and the compile path now initialises first. At the current reference commit, 246f70287, the full check script ran on both hosts after the catch-up closed (merged in 58aa62a81). To fit a full run on one machine, the script had gained per-check timings, a parallel scheduler with a cap on heavy checks, sharded Valgrind and per-check build directories. On macOS, 98 checks were green, among them Miri under both borrow models; the sanitizers and one latency test first failed on two test-only problems, a full disk or machine load, and passed once fixed or re-run, and the others needed tools the Mac does not have. On the Linux VM, 94 checks were green, among them a complete Valgrind run. In a separate run at 58aa62a81, the full Windows workspace suite passed 6,418 tests with none failing. The Linux sanitizer lanes produced no result in that run, so the matrix shows them at a60e9e2a5 until their re-run at the current commit. Taking each lane at its latest complete run, the lanes of the first complete matrix add up to 58,767 test executions under a sanitizer or Valgrind, against 61,403 at 12752b2ac.

How the orchestration changed

For the first two months, the port ran on Workflow scripts. Each one was a JavaScript file that fanned a milestone out to agents in a fixed shape: design, adversarial review of the design, port, verify. 211 were committed, the last on 25 August.

September changed the unit of work. A controlling session now wrote a plan and dispatched one subagent per task, each in its own git worktree. A separate reviewer subagent checked each branch before the controller merged it, one at a time. In September, 1,142 subagents worked across 214 worktrees, and subagents made 88.6% of the model calls. The longest unattended run was 180 commits, overnight from 23 to 24 September.

From workflow scripts to subagents

Read the three bands as one timeline: which model wrote each week's commits, how much context a call carried, and when hand-written workflow scripts gave way to subagent threads.

The port first ran through 211 committed hand-written workflow scripts, none after 08-25. In September an orchestrator dispatched 1,142 subagents across 214 worktrees, and subagents made 88.6% of calls. Median context per call grew from 77k to 204k tokens.

Claude Code and Codex transcripts · git trailersMODELS, CONTEXT AND AGENTSdata
211committed workflow scripts, none after 08-251,142subagents in 214 worktrees in September88.6%of September's calls made by subagents
Opus 4.8Fable 5 with Opus 4.8 (striped)Fable 5Opus 5 (striped)Opus 5.5Sonnet 5.5 (striped)Haiku 5.5no model trailer (striped)
Claude median context (hollow: under 100 calls that week)Codex median contextscripts addedClaude threadsCodex threads
commits per week, by the model in each commit's trailerOpus 4.8Fable 5Opus 5Opus 5.5Mac transcripts lost before 09-08: partial datacontext per call: median, with the 90th-percentile band0200k400kper day: workflow scripts added (up), agent threads (down)last script 08-25threads on a square-root scale; tallest 773JulAugSepOct
commits per week, by modellost before 09-08context per call: median, 90th-percentile band0200k400kper day: scripts added (up), threads (down)last script 08-25threads on a square-root scale; tallest 773JulAugSepOct

Top: non-merge commits per week, to 2026-10-10, by the model named in each commit's Co-Authored-By trailer, stacked around a centre line. Middle: median context per call by week with the 90th-percentile band, from recovered Claude Code and Codex transcripts to 2026-10-08; Mac transcripts before 09-08 were mostly lost, so earlier weeks are partial. Bottom: workflow scripts by the day they were first committed, and agent threads per day in recovered transcripts (square-root scale).

Three things changed with it:

  • Disk became a limit. Each worktree builds its own target directory. On 23 September, about seven building worktrees filled the Mac's disk, and every tool call stalled. Since then, no more than three worktrees build at once, and each worktree and branch is deleted right after its merge.
  • Context per call grew. The median went from 77k to 204k tokens. A controller holding a whole plan, and subagents reading the reference beside the port, both need more context.
  • Models changed with usage allotments. The trailers of the 2,789 non-merge commits on the main line name the model. Opus 4.8 wrote 740 commits in the first five weeks. Opus 5 wrote 615 from late July, and Opus 5.5 1,046 from 23 September. Fable 5 (124), Sonnet 5.5 (57) and Haiku 5.5 (2) filled gaps, 7 commits name both Opus 4.8 and Fable 5, and 198 carry no trailer. Switches often followed a usage allotment running out, as on 10 August: "My Fable usage is about to expire, when it does, continue your work with Opus 5".

Up to 14:00 Pacific on 8 October, my prompts to Claude Code, on the Mac and the Linux VM, numbered 898 on 62 days, not counting the 13 founding prompts or the prompts I typed in the website folders. The change shows in commits per prompt. From 10 July to 31 August, there were 1.7 commits per prompt. From 1 September to 8 October, there were 5.8.

Every prompt

The prompts came in 97 sessions, each one Claude Code or Codex conversation on one machine. 43 of them hold only commands, such as switching the model or clearing the context. The rest are grouped by the part of the work they did, from the terminal engine to this article, under a title that is Claude Code's own, or one written by Claude where Claude Code kept none or I changed it. Sessions often ran side by side: on 31 July, the busiest day, 7 sessions had an instruction or a decision from me, most of them checking the port. Some sessions were resumed again and again; the longest held 168 prompts and spanned 14 days. The founding sessions, 3 of them, hold 13 prompts typed outside the repository. On the night of 20 June, before the repository existed, I weighed a port of Ghostty from Zig to Rust, planned it and named it in one session, and changed a setting in another, in the folder that preceded the repository; on 23 June came a short session in the Ghostty checkout.

The log below holds every prompt I sent to the port from 20 June to 11 October, 1,066 in all, from three sources: the prompt history of Claude Code on the Mac (879) and on the Linux VM (165), and the session logs of the Codex app (22). The founding prompts are part of it. Left out are the prompts I typed while building this website, the prompts that agents wrote for their subagents, and the automated Codex review sessions. Of the prompts, 847 are instructions and 43 only answer a choice or acknowledge; the other 176 are Claude Code commands and shell escapes, kept in the log but hidden until you show them. The text is verbatim, typos included. The internal labels in it, such as task and check names, keep their spelling, and hovering or focusing one explains it. The 60 prompts of the October sessions about this article are tagged as article work. The other prompt figures in this article stop at 14:00 Pacific on 8 October. This section runs to 11 October, when I extracted the prompts, so it also counts the 133 prompts typed after that cutoff, 83 of them port work.

Part of the record is gone. Model, effort and mode are unknown for 463 prompts, most of them typed on the Mac before 8 September, whose transcripts Claude Code deleted. For 96 more, the model shown comes from the Co-Authored-By trailer of a commit linked to the prompt, and is marked as inferred. Pasted text is not published; for 10 prompts it no longer exists. I made 23 redactions, for secrets, logins, email and IP addresses, my username and the name of an unrelated project. Each one reads [redacted: reason], and nothing else in the text was changed.

Sessions and prompts, word for word

Bars are sessions and ticks single prompts, taller for longer text; brush the days, filter by area, lane or kind, or search, then read the matching prompts word for word in the log. The median prompt, counting every kind, is 47.5 characters long.

1,066 human prompts to the port (60 of them about this article) over 114 days: 879 in Claude Code on the Mac, 165 in Claude Code on the Linux VM and 22 in the Codex app. They came in 97 sessions, 43 of them only Claude Code commands or shell escapes; on the busiest day, 2026-07-31, 7 sessions had an instruction or decision, and the longest session held 168 prompts over 14 days. The median prompt is 47.5 characters long (all prompts), and 43 prompts only answer a choice or acknowledge. Model and effort are unknown for 463 prompts, most of them typed on the Mac before 2026-09-08, whose transcripts were purged; for 96 more the model is inferred from commits. The text carries 23 redactions in 18 prompts, and 130 prompts are linked to the commits they led to.

prompt history · Codex sessions · usage transcriptsEVERY HUMAN PROMPT AND SESSION, 20 JUNE TO 11 OCTOBERdata

Top: each bar is one session (one Claude Code or Codex session on one machine) from its first to its last prompt, rows packed so overlapping sessions stack, coloured and named by its area and thicker for more prompts; the 43 sessions that hold only Claude Code commands or shell escapes have no area and are hidden until turned on (then thin grey ticks), and diamonds mark the 3 founding sessions. Session titles are Claude Code's own; where it gave none or the author changed one, Claude wrote it (marked title by Claude). Below, each tick is one human prompt to the port (typed prompt, slash command or shell escape), 2026-06-20 to 2026-10-11 in Pacific time, in three lanes: Claude Code on the Mac, Claude Code on the Linux VM and the Codex app; tick height is length on a log scale, and the bars beneath are non-merge commits per day. By default the ticks, the log and the length figures show instructions and decisions; the 176 Claude Code commands and shell escapes are one click away, and the prompt counts always include them. Mac Claude Code transcripts from before 2026-09-08 were purged, so model and effort are unknown there (hatched) unless the Co-Authored-By trailer of a commit linked to the prompt names the model (marked inferred from commits). Text is verbatim except 23 redactions (secrets, logins, email and IP addresses, names, one unrelated project), each shown as [redacted: reason]; pasted text is not published, and for 10 prompts it no longer exists. The 60 prompts of the October sessions about this article are included and tagged article work. Commits are linked to a prompt only where the session or branch evidence ties them to it unambiguously. The 13 founding prompts (typed outside the repository, most of them before it existed) are included; prompts sent to subagents, automated Codex review sessions, the prompts typed in the website folders and other prompts typed outside the repository are not.

What it cost

The port was paid for by subscription, not per token. Claude Max at the 20x tier covered the whole port. ChatGPT Pro covered one month, 18 July to 18 August, which included the Codex audit, and ChatGPT Plus the other months. The outlay attributable to Rustty, up to 8 October, is about $420:

  • Claude: $361, the plan's $200 a month over the port's 3.61 months, with half counted toward Rustty because the subscription was shared with other projects.
  • Codex: $61. Rustty was 30.5% of that Pro month's Codex usage. Rustty's Codex use on Plus was negligible.

Counting whole billing cycles instead of prorating, or Codex at half, gives a range of ~$420–$600. Any extra-usage credits come on top; their amount is unknown.

What the same tokens would cost at API list prices is a reference value, not money spent. Measuring it has a gap. Claude Code deletes local transcripts after about 30 days, so the Mac Claude telemetry before 8 September is gone. The recovered usage comes to $11,032 at list price, from the Mac and Linux VM Claude transcripts and the complete Codex logs. For the lost window, Mac Claude from 21 June to 7 September, the estimate assumes a maxed-out subscription in every week with Rustty activity, with 50% of it counted toward Rustty. Prompt history from those weeks shows limits being hit throughout. 7 of 15 quiet stretches in the commit history are bracketed by prompts about limits, such as "can we pause here, as I am approaching my token limit." (26 June).

The size of a maxed-out week comes from recovered weeks that ended at the weekly limit: $1,974–$4,262 at list price across all projects, $2,769 as the central value. In the recovered weeks, Rustty's measured share was 53.9%, which supports counting half. The lost window comes to ~$12.8k ($8.7k–$20.3k). The total reference value is ~$23.8k ($19.8k–$31.4k), about 56× what was paid.

The flat price came with a pace. The weekly limit reset every Tuesday afternoon, and the port ran to it. Of the 22 pauses in the commit history of twelve hours or more, 7 ended within five hours of a reset. Between June and August, agents made about 3 commits per running hour in the first three days after a reset and about 1.2 in the rest of the week, and the port ran fewer hours a day as the week went on. The analysis builds the estimate from the hours the port was actually running: about 46 days (41–51 days) instead of 112 days. The estimate keeps every piece of work and every hour I spent reading and answering the agents, and removes the stretches the usage limits left idle, counting the eleven days I split between a limit and other work as half chosen.

Usage, tokens and the subscription

Switch between list-price dollars and tokens, and between Rustty alone and all projects; hollow bars are modeled weeks whose transcripts were lost. Further down, the list price is split by token type, by main thread against subagents and workflow runs, and by era.

The port was paid by subscription: about $420 attributable (range $420–$600, plus unknown extra-usage credits), from Claude Max 20x for the whole port and one month of ChatGPT Pro. At API list prices the same usage would be about $23.8k ($19.8k–$31.4k), a reference value, not money spent. Cache reads were 96.5% of tokens and 55% of the list-price cost.

usage transcripts · prompt history · git trailersSUBSCRIPTION PAID VS LIST PRICEdata

Plotted: API-equivalent list-price cost and billable tokens per call from recovered Claude Code and Codex transcripts (snapshot 2026-10-08). Mac Claude transcripts before 09-08 were purged; for those weeks the port's cost is modeled as a maxed subscription week × 50% port share. Paid figures use public plan prices. The API-equivalent cost is a reference value at list price, not money spent. as of 2026-10-08

The token mix shows where that figure comes from. Cache reads were 96.5% of tokens but 55% of the list-price cost, because every call re-reads its context from the cache. Subagents and workflow runs accounted for 75.6% of the recovered usage at list price. In the recovered window from 8 September to 8 October, a commit came to $5.67 at list price. On the Codex side, one percentage point of the weekly meter corresponded to about $22 of list-price usage.

Unsafe, measured

Early in July I asked the obvious question:

22:28 · "/btw Does the heavy use of unsafe defeat the purpose of Rust, in the port? Or not necessarily"

The answer starts with a count. The port's census is one committed command (.port/audits/count-unsafe.sh):

rg --no-config -n --column -o \
   'unsafe \{|unsafe fn|unsafe extern|unsafe impl|unsafe trait' \
   --glob '*.rs' --glob '!**/vendor/**' --glob '!**/cbits/**' \
   crates crates-sys xtask

It counts 22,240 sites in 838,127 physical lines of tracked Rust. Physical lines here means every line of every tracked .rs file outside vendored code, comments and blank lines included, not the code lines tokei counts. That gives 26.54 sites per 1,000 physical lines. Per code line the density would be higher. The figure has barely moved since 10 July, while the code grew 2.03×. It was 27.02 on 10 July (11,145 sites), peaked at 28.15 on 25 August and read 27.87 at a second census later that day.

Unsafe code per crate, over time

The line follows unsafe sites per thousand physical lines at each census; hover, tap or focus a point for its counts. The bars rank crates by density and split the terminal engine by area.

The port has 22,240 unsafe sites in 838,127 physical lines, 26.54 per thousand. The density stayed nearly flat: 27.02 on 07-10, a peak of 28.15, then 27.87 on 08-25. By a heuristic count, about 58% of the sites are in test code.

unsafe census · tokeiUNSAFE SITES PER 1,000 LINESdata
22,240unsafe sites at head26.54per 1,000 physical lines~58% (52%)in test code (heuristic; stricter rule in brackets)
Density at each census
per 1,000 physical lines2627282927.0228.1527.8726.6626.54physical lines ×2.03 since 07-10JulAugSepOct
Unsafe sites per crate
per 1k physicalrustty-vt29.9rustty-vt-c82.2rustty-xev43.7rustty-inspector88.1rustty-core35.5rustty-renderer18.4rustty-font-shaper23.6rustty-termio19.4rustty-apprt9.6rustty-apprt-c45.3rustty-os14.5rustty-vaxis11.449 other crates5.6
Terminal engine, by area
non-test per 1k codeterminal30.0screen21.8pagelist10.4ref counted set100.4page15.8alloc57.8render20.0kitty2.7stream7.2hyperlink63.4search5.4selection28.852 other areas2.2

Solid = sites in non-test code, hatched = sites in test code (split at each file's first test module). The line and the crate bars use physical lines; the area bars use code lines.

Unsafe sites counted by the port's census pattern (unsafe blocks, functions, extern blocks, impls and traits; vendored code excluded) per 1,000 physical lines of tracked Rust at each census commit, ending at head. Crate bars are labelled per 1,000 physical lines of the crate, engine-area bars per 1,000 code lines. The test/non-test split is a heuristic: everything after a file's first test module counts as test code.

Where the sites sit matters more than the total:

  • Tests. By a heuristic count, about 58% of all sites are in test code. With a stricter rule for what counts as a test file, it is 52%. In the terminal engine, 6,185 sites are in tests against 1,714 outside them. Ghostty's tests manipulate pages, rows and cells through pointers, and the ported tests keep that shape.
  • FFI. Per 1,000 physical lines of each crate, the inspector has 88.1, and 1,147 of 1,155 of its sites are outside tests, because it calls Dear ImGui through C. The terminal library's C API has 82.2.
  • The rest. The terminal engine has 29.9 and the app runtime crate 9.6. The port of the z2d graphics library has none.

A count does not show soundness. The earlier sections cover the parts that do: the bar for calling a soundness bug fixed, the ledger of safe functions that rest on unchecked invariants, and the Miri lanes. The count shows where to look. At head, that is 26.54 sites per 1,000 physical lines, most of them in tests and at C boundaries.

Limitations and what's left

The parity work closed with one difference still open: about 35 log sites print values in Rust's derived Debug format, where Zig prints its own format. Only log text differs, and the severity is low.

Each platform has a one-line claim, worded to match the evidence:

  • macOS: "The main platform: it builds, runs, and carries all the Miri evidence and most of the sanitizer evidence."
  • Linux (GTK): "The GTK app is ported and works in a Linux VM under Wayland and X11, tested by hand and by live-window tests; never run on a real GPU or on x86_64 Linux."
  • Windows: "It builds, lints and passes the full workspace test suite on Windows; like Ghostty at this reference commit, there is no Windows app."
  • FreeBSD: "Built, tested and run natively on FreeBSD in an arm64 VM: the GTK app runs under X11 and Wayland, tested by hand and by live-window tests; three DMA-BUF tests need a GPU driver the VM lacks, and it was never run on amd64." This claim is as of 110adad44, after the snapshot. On FreeBSD 15.1 the first workspace test run gave 6,665 passed and 23 failed; each failure was fixed or explained, and its test binary was run again and passed. The GTK runtime's tests give 163 passed and none failed, and 15 of the 18 live-window tests pass. The other three need DMA-BUF export, which FreeBSD's Mesa provides only with a GPU driver, and arm64 FreeBSD has none for the VM's virtual GPU. The run found bugs in the port: the app crashed at its first window, because the system's text layout library (Pango) mixed Rustty's bundled HarfBuzz with the system's newer one, and the event loop's thread pool could hang at shutdown. It also found that Ghostty's own terminal test expects the terminal's device name on FreeBSD, which Ghostty's code does not report there; Rustty reports it, as on Linux. The fixes that also touch Linux passed again in the Linux VM. I used the app by hand on that VM, typing, colours, CJK text, emoji, resizing and tabs, as the run record states; its setup and logs are committed with it. No sanitizer has run on FreeBSD.
  • WebAssembly: "The terminal core and the font demo compile to WebAssembly and run in a browser, in Node and under wasmtime, where the event loop's WASI backend passes its tests; three example pages give the same output as Ghostty's. There is no web terminal app, and Ghostty has none either." This claim is as of 3a59e7edc, after the snapshot. Under wasmtime the event loop's tests give 34 passed and 2 ignored, because WASI has no threads. In headless Chrome, three of Ghostty's example pages and their Rustty copies, given the same inputs, match in all 139 compared outputs. The same check found that Ghostty's SGR example page has one entry too many in its table of attribute numbers, so it shows most attributes as a neighbour; Rustty's page uses the library's values. I also ran all five example pages by hand in Chrome on 10 October, at that commit; that run is my own statement, with no run record in the repository.

What runs where

Read across a row for what builds, runs and was tested on that platform, and how recent its evidence is. Hover, tap or focus a cell for the record behind it.

macOS is the primary platform: the app builds and runs, with the full Miri and most sanitizer evidence. Linux GTK builds and runs in a virtual machine and passes 18 of 18 live GTK tests. Windows builds and passes the full workspace test suite but, as in Ghostty, has no app. FreeBSD builds and runs the GTK app in an arm64 virtual machine, where its tests pass after the fixes from that run, except three DMA-BUF tests that need a GPU driver the VM lacks (as of a later commit, 110adad44). The WebAssembly build runs in browsers, Node and wasmtime, with three example pages matching Ghostty's output (as of a later commit, 3a59e7edc).

build and test records · Linux, Windows and FreeBSD VMsPLATFORM STATUSdata
PlatformBuildsRunsTested by handTest and sanitizer lanesLatest evidence
macOSarm64 · Swift app over the embedded C API · Metal2026-10-10
The main platform: it builds, runs, and carries all the Miri evidence and most of the sanitizer evidence.
Linux (GTK)aarch64 Fedora 44 VM · Wayland and X11 · OpenGL2026-10-10
The GTK app is ported and works in a Linux VM under Wayland and X11, tested by hand and by live-window tests; never run on a real GPU or on x86_64 Linux.
WindowsWindows 11 ARM64 VM · x86_64 MSVC under emulation, and GNU2026-10-10
It builds, lints and passes the full workspace test suite on Windows; like Ghostty at this reference commit, there is no Windows app.
FreeBSDFreeBSD 15.1 arm64 VM · GTK app, kqueue event loop2026-10-11 as of 110adad44
Built, tested and run natively on FreeBSD in an arm64 VM: the GTK app runs under X11 and Wayland, tested by hand and by live-window tests; three DMA-BUF tests need a GPU driver the VM lacks, and it was never run on amd64.
WebAssemblywasm32 · terminal core, font demo and event loop2026-10-10 as of 3a59e7edc
The terminal core and the font demo compile to WebAssembly and run in a browser, in Node and under wasmtime, where the event loop's WASI backend passes its tests; three example pages give the same output as Ghostty's. There is no web terminal app, and Ghostty has none either.
libvt C APIlibrustty-vt, the terminal library for C2026-10-08
It works on all three desktop OSes and in WebAssembly, with example programs checked against goldens.

● yes◐ partly○ no– not applicableHover, tap or focus a cell for the evidence.

One row per platform: what builds, what runs, what was tested by hand, which verification lanes ran, and the date of the latest evidence, then the claim the article makes. Hover, tap or focus a cell for its evidence. Linux was tested in a Fedora 44 aarch64 virtual machine with software rendering, never on a real GPU or x86_64.

The Linux claim deserves its history. The GTK app was written on two aarch64 Linux VMs by Claude Code sessions running there: 77 commits on the first and about 75 on the second, between 16 and 23 July. A live terminal appeared in a GTK window on 18 July. I then used it next to Ghostty on GNOME under Wayland, in an Xfce session under X11 and in sway, and found real bugs. Typing was swallowed. It crashed after a right-click. Menus showed no shortcuts. The window title format differed, and clear failed on an unknown terminal type. The title bar was striped. After the quick-terminal check in sway, against Ghostty run first as the baseline, my verdict was "Is works exactly as Ghostty to me". All of it ran on software OpenGL. The x86_64 Linux CI jobs are written but have no recorded run.

What is still missing:

  • Current-commit verification. The Linux sanitizer lanes (AddressSanitizer, LeakSanitizer, MemorySanitizer, HWAddressSanitizer and ThreadSanitizer) and the Linux run of the byte-for-byte differential check against Ghostty, with its test drivers built, have not yet run at the current reference commit; the sanitizers' latest results are from the previous one.
  • Windows. The full workspace test suite passes, with three C libraries excluded, but there is no Windows app, as in Ghostty.
  • The z2d port. It is a subset, from 19,812 Zig code lines to 9,907 Rust, and it tracks z2d 0.10.0 while Ghostty's reference commit uses 0.12.1.
  • End-user performance. Every performance figure here is a library micro-benchmark. Startup time, input latency, frame rate and app memory were not measured.
  • Upstream reports. None of the 206 ledger entries has been filed with Ghostty or its dependencies yet.

Appendix: methodology

Every number in this article comes from one fact table. Hover over a number to see its source, its provenance and the commit or date it was measured at. Provenance is one of three kinds. Measured means computed from git, the code or the logs. Documented means read from a record the port keeps. Estimated means a model or a hand classification.

The prose, widgets and data pipeline were drafted with Claude (Claude Code) and reviewed by the author; every number links to its source.

  • Snapshot. Rustty at 58aa62a81, Ghostty at 246f70287, 10 October 2026. Session-derived figures (prompts, usage, cost) stop at 8 October. Days are counted in Pacific time, with 21 June as day 1.
  • Commits. git log at that commit, with merges identified by parent count. Non-merge counts differ slightly by ref set, and each figure's tooltip names its source.
  • Lines. Zig and Rust are compared in tokei code lines, which exclude comments and blank lines, with vendored code excluded. Growth and unsafe density use physical lines of tracked, non-vendored .rs files. The two are never plotted on one axis.
  • Tests. Rust counts #[test] functions. Zig counts test "name" and test { blocks, with identifier-named test blocks noted separately. The per-module comparison maps each Zig module to its Rust counterpart.
  • Comments. A distinctive Ghostty comment, five words or more, counts as carried when it has a fuzzy match among the port's comments after the rename.
  • Models. Co-Authored-By trailers, parsed with git's own trailer parser (git log --format='%(trailers:key=Co-Authored-By,valueonly)'). Commits without a trailer stay unattributed.
  • Prompts and engaged time. Claude Code's prompt history on the Mac and the Linux VM. Engaged hours are an estimate that clusters prompts separated by less than half an hour.
  • Cost. Per-call token counts from the recovered Claude Code and Codex transcripts, priced at LiteLLM's list rates, with cache writes at the five-minute rate. The lost window is modeled as described in the cost section. Paid figures use public plan prices.
  • Performance. A one-to-one port of Ghostty's benchmark tool and byte-identical inputs, Zig built with -Doptimize=ReleaseFast and Rust with a profile that differs from release only in overflow checks. Each figure is the median of seven or nine runs after a warm-up, with Zig re-measured in the same session. Short benchmarks are reported as per-operation slopes over several loop counts, not wall clock.
  • Defects. Parsed from the port's upstream-defect ledger. The class comes from each entry's own kind field. The discovery method and the ReleaseFast consequence are hand classifications.
  • Unsafe. The committed census command shown in the unsafe section.
  • Hardware. A MacBook Pro, Apple M4 Pro, 14 cores, 48 GB. The Linux VM runs Fedora 44, aarch64, 8 vCPU, about 10 GB of RAM, with software OpenGL (llvmpipe) on Apple silicon. Windows builds x86_64 targets (MSVC and GNU) under emulation on a Windows 11 ARM64 VM.