# Keeping a 187,665-line patch on Codex rebasable

Published 2026-10-31. URL: https://ohmydiff.me/blog/rebasable-codex-patch

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`](https://github.com/dzkchen/oh-my-diff/blob/main/upstream.lock.json):

```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`:

```diff
 [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.](/blog/img/rebasable-codex-patch/diagram-1.svg)

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](https://github.com/dzkchen/oh-my-diff/blob/main/mods/reference/host-model.md#omd_mode),
[`runtime/omd/src/mode.rs`](https://github.com/dzkchen/oh-my-diff/blob/main/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`:

```rust
+    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`](https://github.com/dzkchen/oh-my-diff/blob/main/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](https://github.com/dzkchen/oh-my-diff/blob/main/docs/how-omd-works.md#mods-run-in-their-own-processes)
summarizes them.

[`scripts/check_native_ownership.py`](https://github.com/dzkchen/oh-my-diff/blob/main/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`](https://github.com/dzkchen/oh-my-diff/blob/main/runtime/patches/manifest.json) records the patch
hash and, for each of the 736 destination files, its SHA-256 before and after the patch:

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

[`scripts/apply_runtime_patch.py`](https://github.com/dzkchen/oh-my-diff/blob/main/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:

```python
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`](https://github.com/dzkchen/oh-my-diff/blob/main/scripts/normalize_upstream_lock.py)
rewrites exactly those entries to the release version, 170 of them at `0.166.0`:

```diff
 [[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`](https://github.com/dzkchen/oh-my-diff/blob/main/.github/scripts/build.py):

```sh
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:

```json
{"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](https://github.com/dzkchen/oh-my-diff/pull/330): the bump commit
[`e62ada74`](https://github.com/dzkchen/oh-my-diff/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:

```diff
-  "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](https://github.com/dzkchen/oh-my-diff/pull/345), with the bump
commit [`9fdf3b2f`](https://github.com/dzkchen/oh-my-diff/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`](https://github.com/dzkchen/oh-my-diff/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`](https://github.com/dzkchen/oh-my-diff/blob/main/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`](https://github.com/dzkchen/oh-my-diff/commit/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](https://github.com/dzkchen/oh-my-diff/blob/main/docs/support.md)
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](https://github.com/dzkchen/oh-my-diff/blob/main/docs/contributing/runtime.md);
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`](https://github.com/dzkchen/oh-my-diff/commit/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`.
