← Blog

Keeping a 187,665-line patch on Codex rebasable

Oh My Diff (OMD) is the Codex CLI with a host for TypeScript mods, and I ship it as one patch against one exact Codex tag. In OMD 1.4.0 that patch is 187,665 lines across 736 files, pinned to rust-v0.166.0. Between 2026-09-29 and 2026-10-31 it followed nine Codex releases, from 0.159.1 to 0.166.0. When I moved it from 0.165.0 to 0.166.0, 597 of its 717 file entries came through the bump commit unchanged apart from index lines and line offsets. I keep edits to upstream files small and use scripts to check that applying the patch reproduces the same tree.

Every count here is from the OMD 1.4.0 release commit ff44f45e (2026-11-02), on Codex 0.166.0, measured from the repository and its git history. OMD 1.3.0 was the first release on 0.166.0. OMD is in beta and runs on Apple Silicon macOS only. I did not record how many hours each rebase took.

Forks and requirements

The usual way to change Codex's behavior is a fork, a branch with your commits that you merge with upstream on every release. A fork can change anything, but each merge gets harder with every line you touch in files upstream also edits. Between the 0.163.0 and 0.164.0 tags, upstream changed 1,927 paths (git diff --name-only --no-renames between the two tags).

I had three requirements. Mods must change parts of Codex that a wrapper process cannot access: the composer, tool dispatch, compaction, terminal panels. With no mods on, the binary must behave like upstream. And each Codex release must be cheap enough to follow within days, because users are told to hold back Codex updates until OMD supports the new version.

Repository layout

The repository holds no Codex source, only a patch and a pin. The pin, the exact upstream tag and commit the patch applies to, is upstream.lock.json:

{
  "repository": "https://github.com/openai/codex.git",
  "tag": "rust-v0.166.0",
  "commit": "6ff1e1ca3b30fc8b914720681fcf5fe62e95f856",
  "rust_toolchain": "1.95.0",
  "verified_target": "aarch64-apple-darwin",
  "cargo_lock_sha256": "22bd51f5...",
  "normalized_cargo_lock_sha256": "360ea1e0...",
  "normalized_workspace_packages": 170
}

runtime/patches/ contains exactly two files: omd-0.166.0.patch (7,603,896 bytes) and manifest.json. The patch adds three crates to the Codex workspace and edits upstream crates to call them. This is its first hunk, in codex-rs/Cargo.toml:

 [workspace]
 members = [
+    "omd",
+    "omd/runner",
+    "ext/omd",
     "aws-auth",

So that reviewers can read the host without applying a 7 MB patch, runtime/omd and runtime/native are byte copies of the codex-rs/omd and codex-rs/ext/omd crates from the patched tree, 95,469 lines of Rust including tests. The build script writes them, and nobody edits them by hand.

Build flow: the Codex tag rust-v0.166.0 goes through normalize_upstream_lock.py and apply_runtime_patch.py to produce the patched Codex tree. That tree contains codex-omd (the generic host), codex-omd-runner (omd-runner with embedded V8), codex-omd-extension (wiring into threads, turns and tools), edits in core, tui and app-server, and the signed codex and omd-runner binaries. The codex launcher exports OMD_MODE=omd and TypeScript mods run in runner processes.

At run time, the mode comes from one environment variable. OMD_MODE=omd turns the mod host on. upstream_direct or no value gives plain upstream behavior, and any other value fails at startup (OMD_MODE reference, runtime/omd/src/mode.rs). The installed codex launcher is a shell script that exports OMD_MODE=omd and the mod store path before it starts the binary. Patched code paths have to behave like upstream when the variable is unset, and tests cover that direct mode.

New files and edited files

A new file conflicts only if upstream creates the same path. An edit to an upstream file conflicts whenever upstream edits nearby lines. So I try to keep the edited part small. I counted the diff --git entries in omd-0.166.0.patch:

Part of the patch Files Lines
New files 255 138,507 added
Edits to existing upstream files 481 17,737 added, 1,727 removed

New files contain 88.6% of the added lines. The edits are mostly small. The median edited file changes 11 lines, 239 of the 481 change 10 or fewer, and 22 change more than 200. The new lines are concentrated in OMD's own crates:

Crate directory Lines in new files Changed lines in upstream files
codex-rs/omd 64,824 0
codex-rs/ext/omd 33,985 0
codex-rs/tui 28,465 6,546
codex-rs/core 4,362 6,833
codex-rs/app-server 2,358 1,500
codex-rs/app-server-protocol 769 1,632
codex-rs/ext/extension-api 147 650

These counts include generated files. Cargo.lock accounts for 650 changed lines, and two app-server JSON schemas for 453 each.

The TUI crate is split the same way. The terminal code that draws mod surfaces is in new files such as tui/src/mod_ui.rs (5,927 lines) and tui/src/app/mod_surface.rs (2,479 lines). The edits to upstream TUI files call into them. chat_composer.rs is the largest of those edits at 479 changed lines.

Codex's extension API

Codex already has an extension API. At rust-v0.166.0, codex-rs/ext/ contains extension-api and 17 extension crates (goal, memories, web-search and others). OMD's integration crate, codex-omd-extension, is one more directory there: codex-rs/ext/omd.

Where the extension API lacked a hook, I added one. The patch changes 13 files in ext/extension-api: 797 added lines and no removed ones. It adds six traits: ApprovalGateContributor, ToolBatchContributor, ToolSpecContributor, AgentRoleContributor, ToolOutputContributor and ToolInvoker. These additions can still conflict when upstream edits the same region, but the patch removes no line from the extension API. Of the 30 trait methods it adds in contributors.rs, the 6 that have no default implementation belong to the new traits, so upstream's own extensions compile unchanged.

Where the extension API exposes no hook, the patch edits core directly, mainly in tool dispatch. OMD splits a tool call into a prepare step and a dispatch step, so mods can rewrite or intercept a call before it runs. That needs new methods on upstream's router, such as this hunk in core/src/tools/router.rs:

+    pub(crate) fn replace_model_visible_specs(&mut self, specs: Vec<ToolSpec>) {
+        self.model_visible_specs = specs.into();
+    }

The other direct edits are in session and turn code, compaction, the composer and key handling, and the app-server thread processor.

The native ownership check

The rule in AGENTS.md is that new features are mods, and the patched Codex code only gets building blocks that several unrelated mods can use. In 1.4.0 the features are TypeScript packages in mods/first-party (44 of them) and the bundled plugins and skills in plugins/. A feature written in Rust adds native code that has to survive every rebase, and the same feature written as a mod adds none.

codex-omd is the host. It loads mod packages, checks their declared capabilities, runs each callback, accepts or rejects its proposed state and result, and combines output from several mods. codex-omd-extension connects it to Codex threads, turns, tools, accounting and the workspace. codex-omd-runner builds the omd-runner binary, which runs mod code in sandboxed child processes on embedded V8 and talks to the host over a request and reply protocol. Earlier versions ran mods on Node. The host and runner internals are a separate topic, and how OMD works summarizes them.

scripts/check_native_ownership.py (mise run native:ownership) enforces this rule. It builds a vocabulary from every first-party mod and bundled plugin id, splits each id into words, and removes generic host words such as plan, panel, tool and usage. omd_frecency contributes frecency. omd_plan_panel contributes no single word, because both of its words are generic, but a multi-word id also stays in the vocabulary whole, so it contributes plan_panel. The script then scans native string literals, the host's published surface lists, config struct fields and the runner's JavaScript for vocabulary words. A string literal "frecency_score" in runtime/omd/src fails the check. It also scans every added line in the patch outside the mirrored crates and tests for a first-party mod id, in snake, kebab or CamelCase form and, for multi-word ids, also without the omd_ prefix, which covers the TUI and core edits.

The same script rejects these crate dependencies:

Crate Must not depend on
codex-omd codex-omd-extension, codex-core, codex-tui, codex-app-server
codex-omd-extension codex-tui, codex-app-server

A host that can't import codex-core can't call core internals that change between releases. On 2026-11-02, at the 1.4.0 release commit, the check reports "count": 0.

Patch manifest and apply script

manifest.json records the patch hash and, for each of the 736 destination files, its SHA-256 before and after the patch:

"codex-rs/Cargo.toml": {
  "before": "4f9c16fb73cc8487e40dcbfca545f69e7595e6806e1a59b81ea8045b15364e49",
  "after": "41672e1038675944f9cad5cd7e2e02484b745de51fe4f33ad71a1a180e266651"
},

scripts/apply_runtime_patch.py refuses a checkout at any other commit and a patch whose hash differs. It then hashes every destination and compares it with the manifest:

if current not in (hashes["before"], hashes["after"]):
    raise ValueError("refusing to overwrite modified patch destination: " + name)
if all(states[name] == hashes["after"] for name, hashes in manifest["files"].items()):
    return "Pinned runtime patch already applied"
if not all(states[name] == hashes["before"] for name, hashes in manifest["files"].items()):
    raise ValueError("partially applied patch; restore a clean pinned checkout")
Destination hashes Result
Any file matches neither before nor after Refused: modified destination
Every file matches after No-op: already applied
Every file matches before git apply --check, git apply, then every file is hashed again and compared with after
A mix of before and after Refused: partially applied

Because of the no-op case, applying the patch twice is safe.

Before the patch is applied, one more script runs. Upstream's Cargo.lock lists its workspace crates at version = "0.0.0". scripts/normalize_upstream_lock.py rewrites exactly those entries to the release version, 170 of them at 0.166.0:

 [[package]]
 name = "codex-core"
-version = "0.0.0"
+version = "0.166.0"

It aborts if the count or the resulting hash differs from the pin. That keeps the patch's Cargo.lock hunk stable, and the two lock hashes in the pin catch an unexpected upstream lockfile.

Syncing with build.py

Nobody edits runtime/ by hand. Contributors change Rust in a scratch checkout and write the result back with .github/scripts/build.py:

python3 .github/scripts/build.py setup NAME
python3 .github/scripts/build.py cargo NAME LOG test --locked -p CRATE FILTER
python3 .github/scripts/build.py sync NAME
python3 .github/scripts/build.py locks

setup clones the pinned tag once, adds a worktree at /private/tmp/omd-bNAME/codex, normalizes the lockfile and commits that as a tag named normalized1660, then applies the patch. That tag is the pinned upstream source, so git diff normalized1660 -- codex-rs/PATH shows OMD's change to any file.

sync runs in the other direction. It diffs the tree against the normalized1660 tag and rewrites the patch, the manifest, the two mirror crates and runtime/crate-notices.txt. Then it replays the result. It makes a fresh --shared clone at the pinned commit, normalizes it, applies the new patch twice, and compares the file inventory and every file byte for byte with the scratch tree. It fails with replayed file differs: PATH on any mismatch. On success it prints a JSON report with this shape:

{"patch_sha256": "...", "patch_bytes": ..., "patch_files": ..., "mirrors_updated": ...,
 "normalize": "Normalized 170 workspace package versions; all external dependencies unchanged",
 "first_apply": "Applied and verified the pinned runtime patch",
 "second_apply": "Pinned runtime patch already applied", "tree_parity": true, "files_compared": ...}

The scripts return these fields. The omitted values depend on the scratch tree.

CI repeats this. The lint job in nightly and release validation applies and re-syncs the patch and fails if the result differs from the checkout, then runs Clippy on patched paths and the ownership check. Any pull request that touches runtime/, including Markdown in the mirrors, selects the macOS patch replay and drift check plus a compile. The nightly compile cache key includes hashes of upstream.lock.json and manifest.json, so a pin bump starts a fresh cache.

Codex bumps

The 0.165.0 upgrade is PR #330: the bump commit e62ada74 and a merge of main, d7173ac3. The bump commit changes 28 files. It renames the patch, regenerates the manifest, extends the app-server route allowlist, updates version mentions and changes the pin:

-  "tag": "rust-v0.164.0",
-  "commit": "6aaac694803004a63c6f393d153c96d22521a6b9",
+  "tag": "rust-v0.165.0",
+  "commit": "360319d8dee62d87e2d59fc0c21805d79519cfed",
...
-  "normalized_workspace_packages": 166
+  "normalized_workspace_packages": 167

The upstream tag's commit is dated 2026-10-27, and the pin commit is dated 2026-10-28.

The 0.166.0 upgrade has the same shape: PR #345, with the bump commit 9fdf3b2f and a merge of main, 91c1bd06. That bump commit changes 43 files. Besides the pin, patch, manifest, route allowlist and version mentions, it carries changes to the omd_statusline and omd_usage mods. The upstream tag's commit is dated 2026-10-29, and the pin commit is dated 2026-10-31.

For the last five bumps, I counted which upstream changes touched files in the patch and which patch entries the upgrade commit rewrote. Upstream paths come from git diff --name-only --no-renames -z between the two tags. Destinations are the files in the manifest immediately before the bump. An entry counts as unchanged if its text is identical once index lines and hunk headers are ignored.

Bump Upstream paths changed Of those, patch destinations Patch entries unchanged by the bump commit Conflicts recorded
0.162.0 to 0.162.1 10 4 of 682 640 of 682 none recorded
0.162.1 to 0.163.0 1,308 171 of 619 480 of 619 24 files, 28 hunks
0.163.0 to 0.164.0 1,927 221 of 662 550 of 662 none recorded
0.164.0 to 0.165.0 685 95 of 687 623 of 687 none recorded
0.165.0 to 0.166.0 1,707 237 of 717 597 of 717 none recorded

The 0.163.0 bump commit also contained feature work, so its unchanged count understates how much carried over. The conflict count for 0.163.0 comes from my notes for that upgrade. I didn't record conflicts for the other bumps, so for those rows I don't know the count.

In the 0.165.0 bump, 64 entries changed. Two were binary schema archives (*.json.zst) that are regenerated after the build. The other 62 changed 518 lines in total. The largest was tui/src/transcript_view/layout.rs at 145 lines, followed by core/src/tools/registry.rs and core/src/tools/invoker.rs. In the mirrors, the bump changed one line each in runtime/omd/src/mods.rs and runtime/native/Cargo.toml, and runtime/omd/src/app_routes.json gained 20 lines and lost 2.

In the 0.166.0 bump, 120 entries changed, two of them the same schema archives, and the patch gained 16 entries, all edits to upstream files, which took it from 717 to 733 entries.

For the point release 0.162.1, the bump commit aff80600 rewrote 95 lines of a 154,636-line patch file.

Upstream removals and recurring work

The 0.163.0 upgrade is the one I have notes for. Of 1,308 changed paths, 171 were patch destinations and 24 conflicted. In tool dispatch (registry.rs, router.rs, parallel.rs), upstream had added cancellable lock acquisition, and I kept OMD's prepare and dispatch split on top of it. Compaction had a new upstream file to hook into. Nine TUI files conflicted, among them the list selection view, whose new upstream render body runs inside OMD's own render path.

Upstream removals also changed OMD's public mod API. Upstream deleted the unified-exec shell mode and the zsh-fork path that intercepted execve calls for approval, which showed up as compile errors in the patched tree. OMD had passed those approvals to mods, so the 'execve' approval kind came out of the public mods/api.d.ts and the reference, and the approval_action:Execve key came out of the route table in scripts/upstream_routes.json. Upstream's GuardianAssessmentAction::Execve still exists at 0.163.0. Upstream also dropped socket2 as a workspace dependency, and the patch adds it back because codex-omd uses it. It's the socket2 = "0.6.1" line in the Cargo.toml hunk.

OMD guards the app-server surface with an allowlist. runtime/omd/src/app_routes.json lists the 115 app-server requests mods may call, with the fields each may pass. A new upstream field isn't visible to mods until it's added there. The 0.165.0 bump added attachToSource, attachedToThreadId and the thread/memory/read request. The 0.166.0 bump added the accountSession/list and accountSession/switch requests.

Some work recurs on every bump:

  • Insta snapshot files in the TUI change. The 0.165.0 bump rewrote several .snap entries in the patch.
  • The app-server JSON and TypeScript schemas are patched files. I regenerate them in the scratch tree and don't merge them by hand.
  • runtime/native/src/request_observer.rs is the one mirror of an upstream file, the patched codex-api/src/request_observer.rs, and sync copies it explicitly.
  • Clippy warnings appear in OMD code after a bump. The ones after 0.164.0 were fixed in a63076dd.

Alternatives

A fork kept as a git branch can make any change, and its upgrades are ordinary git merges. The same edits collide either way, so a fork has the same conflicts as the patch. A branch gives up the exact pin with per-file hashes, a public tree with no upstream code in it, and readable mirrors of the host.

A wrapper that starts stock Codex and talks to it from outside needs no patch at all. It can't reach the composer, tool dispatch or compaction, which are the surfaces mods needed.

Codex's own plugins, hooks, skills and MCP servers are the right tool when they cover the need, and OMD ships bundled plugins and skills in plugins/. The patch exists for the surfaces those mechanisms don't expose.

I have no measured comparison between these approaches. If Codex exposed the composer, tool dispatch and compaction to plugins or hooks, those could replace parts of the patch.

Costs and limits

OMD supports one Codex minor line per release. The installer requires upstream Codex 0.166.x (UPSTREAM_MINOR = "0.166" in install.sh), and the support page tells users to turn off automatic Codex updates until omd update supports the new version. If OMD falls behind, users stay on an older Codex.

The patch keeps growing as OMD gains host surfaces. It went from 64,838 lines at the 0.159.1 pin to 173,318 at the 0.165.0 pin, 187,131 at the 0.166.0 pin and 187,665 in 1.4.0. The edited part grew with it, from 262 upstream files at 0.159.1 to 481 in 1.4.0, and upstream changes can conflict with any of those files.

A scratch tree needs 15 to 20 GB for its target and a few minutes for a first test build. Those figures are estimates from the runtime contributing guide; I haven't timed them. OMD builds only for aarch64-apple-darwin. The driver I use for upgrades, with its conflict candidate tree, is maintainer-only. In 6f50084c I removed its surface route tooling (upstream_routes.json and upstream_surfaces.py) from the public tree, so the public repository contains the result of each bump but not the procedure that produced it.

I also can't prove that the no-features rule reduces conflicts. The added-to-edited ratio only describes the patch. Conflicts come from the 481 edited upstream files, and 22 of them change more than 200 lines each. Apart from Cargo.lock and two generated schemas, the largest are the app-server thread_processor.rs (515 lines), chat_composer.rs (479), core/src/tools/parallel.rs (469) and the session and turn code. Tool dispatch and the thread processor both conflicted in 0.163.0.