patch-cc 0.3.3__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {patch_cc-0.3.3 → patch_cc-0.4.0}/PKG-INFO +11 -9
  2. {patch_cc-0.3.3 → patch_cc-0.4.0}/README.md +10 -8
  3. {patch_cc-0.3.3 → patch_cc-0.4.0}/docs/INTERNALS.md +85 -45
  4. {patch_cc-0.3.3 → patch_cc-0.4.0}/docs/PLAYBOOK.md +68 -6
  5. {patch_cc-0.3.3 → patch_cc-0.4.0}/docs/corpus.md +16 -7
  6. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/__init__.py +1 -1
  7. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/bun/blob.py +83 -30
  8. patch_cc-0.4.0/src/patch_cc/bun/container.py +208 -0
  9. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/cli.py +11 -1
  10. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/js.py +388 -135
  11. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/patcher.py +21 -22
  12. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/patches/agents.py +9 -3
  13. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/patches/chrome.py +19 -10
  14. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/patches/codex.py +1 -3
  15. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/patches/streaming.py +185 -37
  16. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/patches/thinking.py +4 -4
  17. patch_cc-0.3.3/src/patch_cc/bun/container.py +0 -183
  18. {patch_cc-0.3.3 → patch_cc-0.4.0}/.github/workflows/ci.yml +0 -0
  19. {patch_cc-0.3.3 → patch_cc-0.4.0}/.github/workflows/release-watch.yml +0 -0
  20. {patch_cc-0.3.3 → patch_cc-0.4.0}/.github/workflows/release.yml +0 -0
  21. {patch_cc-0.3.3 → patch_cc-0.4.0}/.gitignore +0 -0
  22. {patch_cc-0.3.3 → patch_cc-0.4.0}/AGENTS.md +0 -0
  23. {patch_cc-0.3.3 → patch_cc-0.4.0}/CLAUDE.md +0 -0
  24. {patch_cc-0.3.3 → patch_cc-0.4.0}/LICENSE +0 -0
  25. {patch_cc-0.3.3 → patch_cc-0.4.0}/docs/CONDUCT.md +0 -0
  26. {patch_cc-0.3.3 → patch_cc-0.4.0}/docs/demo.gif +0 -0
  27. {patch_cc-0.3.3 → patch_cc-0.4.0}/pyproject.toml +0 -0
  28. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/bun/__init__.py +0 -0
  29. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/bun/elf.py +0 -0
  30. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/bun/errors.py +0 -0
  31. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/bun/macho.py +0 -0
  32. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/cache.py +0 -0
  33. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/codex/__init__.py +0 -0
  34. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/codex/gateway.py +0 -0
  35. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/codex/models.py +0 -0
  36. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/codex/oauth.py +0 -0
  37. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/codex/translate.py +0 -0
  38. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/doctor.py +0 -0
  39. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/locate.py +0 -0
  40. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/menu.py +0 -0
  41. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/patches/__init__.py +0 -0
  42. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/patches/base.py +0 -0
  43. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/patches/output.py +0 -0
  44. {patch_cc-0.3.3 → patch_cc-0.4.0}/src/patch_cc/ui.py +0 -0
  45. {patch_cc-0.3.3 → patch_cc-0.4.0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: patch-cc
3
- Version: 0.3.3
3
+ Version: 0.4.0
4
4
  Summary: Interactive patcher for the Claude Code native binary
5
5
  Project-URL: Homepage, https://github.com/anfreire/patch-cc
6
6
  Project-URL: Repository, https://github.com/anfreire/patch-cc
@@ -198,14 +198,16 @@ startup name / `--version` marker are visible tells too.
198
198
  ## Why native-only, and why it stays small
199
199
 
200
200
  Claude Code now ships only as a Bun single-file executable; the npm package is a
201
- wrapper that downloads it. patch-cc edits the JavaScript bundle embedded in the
202
- binary's `.bun` section in place. It also drops the entry module's stale
203
- precompiled bytecode editing the source invalidates it anyway, and it is more
204
- than half the download so on Linux, where the ELF section is rewritten in
205
- place, a patched binary is *smaller* than the original, not larger. (On macOS
206
- the freed bytes are not yet reclaimed, so the file keeps its size; it still runs
207
- correctly.) [docs/INTERNALS.md](docs/INTERNALS.md#the-bytecode-and-why-we-drop-it)
208
- has the measurements; `patch-cc status` has yours.
201
+ wrapper that downloads it. patch-cc edits the JavaScript modules embedded in the
202
+ binary's `.bun` section in place since 2.1.242 the app is code-split across
203
+ ~1,300 of them, and patch-cc treats every one as a single surface. It also drops
204
+ the stale precompiled bytecode of the modules it edits editing a module's
205
+ source invalidates its bytecode anyway so on Linux, where the ELF section is
206
+ rewritten in place, a patched binary is *smaller* than the original, not larger
207
+ (83 MB smaller on 2.1.243). (On macOS the freed bytes are not yet reclaimed, so
208
+ the file keeps its size; it still runs correctly.)
209
+ [docs/INTERNALS.md](docs/INTERNALS.md#the-bytecode-and-why-we-drop-it) has the
210
+ measurements; `patch-cc status` has yours.
209
211
 
210
212
  See [docs/INTERNALS.md](docs/INTERNALS.md) for the container format and
211
213
  [docs/PLAYBOOK.md](docs/PLAYBOOK.md) for repairing a patch after an update.
@@ -167,14 +167,16 @@ startup name / `--version` marker are visible tells too.
167
167
  ## Why native-only, and why it stays small
168
168
 
169
169
  Claude Code now ships only as a Bun single-file executable; the npm package is a
170
- wrapper that downloads it. patch-cc edits the JavaScript bundle embedded in the
171
- binary's `.bun` section in place. It also drops the entry module's stale
172
- precompiled bytecode editing the source invalidates it anyway, and it is more
173
- than half the download so on Linux, where the ELF section is rewritten in
174
- place, a patched binary is *smaller* than the original, not larger. (On macOS
175
- the freed bytes are not yet reclaimed, so the file keeps its size; it still runs
176
- correctly.) [docs/INTERNALS.md](docs/INTERNALS.md#the-bytecode-and-why-we-drop-it)
177
- has the measurements; `patch-cc status` has yours.
170
+ wrapper that downloads it. patch-cc edits the JavaScript modules embedded in the
171
+ binary's `.bun` section in place since 2.1.242 the app is code-split across
172
+ ~1,300 of them, and patch-cc treats every one as a single surface. It also drops
173
+ the stale precompiled bytecode of the modules it edits editing a module's
174
+ source invalidates its bytecode anyway so on Linux, where the ELF section is
175
+ rewritten in place, a patched binary is *smaller* than the original, not larger
176
+ (83 MB smaller on 2.1.243). (On macOS the freed bytes are not yet reclaimed, so
177
+ the file keeps its size; it still runs correctly.)
178
+ [docs/INTERNALS.md](docs/INTERNALS.md#the-bytecode-and-why-we-drop-it) has the
179
+ measurements; `patch-cc status` has yours.
178
180
 
179
181
  See [docs/INTERNALS.md](docs/INTERNALS.md) for the container format and
180
182
  [docs/PLAYBOOK.md](docs/PLAYBOOK.md) for repairing a patch after an update.
@@ -13,7 +13,11 @@ binary:
13
13
  - **Windows**: a PE `.bun` section (not supported here)
14
14
 
15
15
  Inside that section is a *Bun module graph*: a flat arena of payloads, a module
16
- table describing them, and a trailer.
16
+ table describing them, and a trailer. Before 2.1.242 the graph was the app in one
17
+ module plus a few asset modules; since then the app is **code-split** across
18
+ ~1,300 `chunk-*.js` modules that the entry lazily imports (see
19
+ [the split](#the-21242-split-and-the-patchable-surface)). Either way patch-cc
20
+ treats every module the container declares to be JavaScript as one surface.
17
21
 
18
22
  ```
19
23
  .bun section
@@ -35,52 +39,85 @@ A module record (new 52-byte format) is six such pairs — `name`, `contents`,
35
39
  `sourcemap`, `bytecode`, `moduleInfo`, `bytecodeOriginPath` — followed by four
36
40
  `u8` flags (`encoding`, `loader`, `moduleFormat`, `side`).
37
41
 
38
- The module we patch is the entrypoint, which the offsets struct names by index
39
- (`entry_point_id`) — the same index Bun itself resolves it by. Its `contents` is
40
- the JS we edit. Its *name* is upstream's to change and we never read it:
41
- 2.1.229 renamed it `/$bunfs/root/src/entrypoints/cli.js` → `/$bunfs/root/cli`.
42
-
43
42
  Code: `src/patch_cc/bun/blob.py`.
44
43
 
45
- ## The bytecode, and why we drop it
46
-
47
- The entry module also carries precompiled Bun **bytecode** — more than half the
48
- binary. Every other module has none.
44
+ ## The 2.1.242 split, and the patchable surface
49
45
 
50
- Any edit to `contents` invalidates that bytecode; Bun detects the mismatch and
51
- recompiles from source at launch. So keeping it buys nothing:
46
+ Through 2.1.241 the entrypoint module *was* the app: one ~28 MB `contents`
47
+ carrying every line patch-cc anchors on. 2.1.242 turned on Bun code-splitting
48
+ with lazy loading, and the shape changed under the tool:
52
49
 
53
- | binary | size | startup |
50
+ | build | modules | entrypoint `contents` |
54
51
  |---|---|---|
55
- | original (valid bytecode) | 323 MB | ~100 ms |
56
- | patched, bytecode kept | 323 MB | ~650 ms |
57
- | patched, bytecode dropped | **125 MB** | ~650 ms |
58
-
59
- Patching pays the recompile cost either way, so patch-cc drops the entry
60
- module's bytecode (`rebuild(..., drop_bytecode=True)`). The result runs source,
61
- guaranteeing our edits are authoritative, and on Linux is smaller by
62
- exactly the bytecode.
63
-
64
- The size figures are the **ELF** path: the `.bun` section is rewritten in place,
65
- so the dropped bytecode is genuinely reclaimed (`container.verify` refuses a
66
- Linux write that did not shrink). On **macOS** the file keeps its original size:
67
- `macho.py` grows a segment but never shrinks one, so the freed bytes stay as
68
- dead space. The binary still runs correctly (the bytecode is gone), it is just
69
- not smaller reclaiming it means shrinking the Mach-O segment and re-laying
70
- `__LINKEDIT`, which is not done yet.
71
-
72
- Those are **2.1.232's** Linux numbers, and they are a measurement rather than a
73
- promise: the same table read 267 / 113 MB against 2.1.216, because the bytecode
74
- grew from 154 MB to 198 MB in the fifteen builds between them (2.1.230 was
75
- never published). Read the current pair off any
76
- binary with `patch-cc status` rather than off this table.
77
-
78
- Every write asserts the binary it produced carries `bytecode == 0`
79
- (`container.verify`, beside the round-trip check), and `patch-cc status` reports
80
- the field for an installed one. `doctor` cannot: a dry run is handed a *clean*
81
- bundle, which still has its bytecode by definition. If a future Bun build makes
82
- bytecode authoritative over source, that assert is the tripwire — every patch
83
- would silently no-op otherwise.
52
+ | 2.1.241 | 11 | 28,249,679 bytes (the whole app) |
53
+ | 2.1.243 | 1,385 | 19,952 bytes (an argv shim) |
54
+
55
+ The entrypoint is now a ~20 KB shim that parses argv and lazily
56
+ `import()`s the app across ~1,300 `/$bunfs/root/chunk-*.js` modules; ~46 MB of
57
+ JS, the largest chunk 7.3 MB. The code did not disappear — every anchor is still
58
+ in the binary but it left the one module patch-cc used to read, and it does not
59
+ concentrate in a single chunk (`branding` spans a dozen modules, `org-label` ten,
60
+ `spinner-tips` six).
61
+
62
+ So the patchable surface is **every module the container declares to be
63
+ JavaScript**, discovered the way the entrypoint itself is discovered off the
64
+ artifact, never hardcoded. A module's *loader* (the second trailing flag) is how
65
+ Bun decides whether to compile it as source or hand it over as opaque bytes, and
66
+ the entrypoint is by definition the JS Bun runs, so its loader *is* the JS loader
67
+ (`Blob.js_modules`). The asset modules (the native addons, the bundled
68
+ `mermaid`/`hljs`, the HTML template) carry other loaders and are left alone. A
69
+ pre-split build is the one-module case of this `js_modules()` returns just the
70
+ monolith so [`js.Source`](PLAYBOOK.md#the-many-module-surface) spans one module
71
+ or a thousand through the same code.
72
+
73
+ The entrypoint still matters for one thing: it is where the manifest lives and
74
+ what `status` reads, named by the offsets struct's `entry_point_id` — the same
75
+ index Bun resolves it by. Its *name* is upstream's to change and we never read
76
+ it: 2.1.229 renamed it `/$bunfs/root/src/entrypoints/cli.js` `/$bunfs/root/cli`.
77
+
78
+ ## The bytecode, and why we drop it
79
+
80
+ Modules carry precompiled Bun **bytecode** — most of the binary. Before the
81
+ split only the entry module had any (~half of it); the code-split builds carry it
82
+ on nearly every chunk (232 MB of 377 on 2.1.243, across ~1,375 modules).
83
+
84
+ Any edit to a module's `contents` invalidates *that module's* bytecode; Bun
85
+ detects the mismatch and recompiles that module from source at launch. So keeping
86
+ a stale copy buys nothing — the recompile is paid either way — and dropping it
87
+ reclaims the space and guarantees our edits are what runs. patch-cc drops the
88
+ bytecode of exactly the modules it edited (`rebuild` over `changed_modules`) and
89
+ leaves every untouched module its bytecode and its fast start. On Linux, where
90
+ the ELF section is rewritten in place, the binary is smaller by exactly the
91
+ edited modules' bytecode.
92
+
93
+ Measured on 2.1.243, the full patch set:
94
+
95
+ | binary | size | bytecode | startup |
96
+ |---|---|---|---|
97
+ | pristine | 378 MB | 232 MB (every module) | ~13 ms `--version` |
98
+ | patched (edited modules' bytecode dropped) | **295 MB** | 150 MB (untouched modules) | ~15 ms |
99
+
100
+ The 83 MB reclaimed is the ~45 edited modules' bytecode; the rest stays, which is
101
+ why a split-build patched binary is smaller but not the *half* a patched monolith
102
+ was. The recompile is now per lazily-imported edited module rather than the whole
103
+ app at once, so startup barely moves. Read the current figures off any binary
104
+ with `patch-cc status` rather than off this table — the bytecode total grows every
105
+ few builds.
106
+
107
+ The size story is the **ELF** path: the `.bun` section is rewritten in place, so
108
+ the dropped bytecode is genuinely reclaimed (`container.verify` refuses a Linux
109
+ write that did not shrink by about that much). On **macOS** the file keeps its
110
+ original size: `macho.py` grows a segment but never shrinks one, so the freed
111
+ bytes stay as dead space. The binary still runs correctly (the bytecode is gone),
112
+ it is just not smaller — reclaiming it means shrinking the Mach-O segment and
113
+ re-laying `__LINKEDIT`, which is not done yet.
114
+
115
+ Every write asserts each **edited** module carries `bytecode == 0` in the binary
116
+ it produced (`container.verify`, beside the round-trip check), and `patch-cc
117
+ status` reports the total for an installed one. `doctor` cannot: a dry run is
118
+ handed a *clean* bundle, which still has all its bytecode by definition. If a
119
+ future Bun build makes bytecode authoritative over source, that assert is the
120
+ tripwire — every edit would silently no-op otherwise.
84
121
 
85
122
  ## Writing it back without ballooning
86
123
 
@@ -108,8 +145,10 @@ edit is followed by an ad-hoc `codesign` (mandatory on Apple Silicon).
108
145
 
109
146
  ## The manifest
110
147
 
111
- Every patched bundle ends with a single comment line — the one description of
112
- its shape; [PLAYBOOK.md](PLAYBOOK.md) covers what it means for matcher health:
148
+ Every patched bundle carries a single comment line — appended to the **entry
149
+ module**, the one module always present and always re-extracted, and the one
150
+ `status` reads — describing its shape; [PLAYBOOK.md](PLAYBOOK.md) covers what it
151
+ means for matcher health:
113
152
 
114
153
  ```
115
154
  //patch-cc {"v":1,"tool":"<version>","patches":[...],"brand":...,"suffix":...,
@@ -157,7 +196,8 @@ rather than from a store of their own.
157
196
  and dropped rather than aborting the run. See
158
197
  [PLAYBOOK.md](PLAYBOOK.md#the-syntax-gate).
159
198
  - Every write is verified: patch-cc re-extracts the JS from the binary it just
160
- wrote and asserts it equals what it meant to write.
199
+ wrote and asserts every module equals what it meant to write, and that each
200
+ module it edited carries no leftover bytecode to run instead of the edit.
161
201
  - Patching a binary that is already marked, when no pristine backup exists, is
162
202
  refused outright — there is nothing clean to start from, and our edits change
163
203
  lengths, so a second pass would corrupt rather than update. `restore` or a
@@ -152,6 +152,56 @@ gone green, and every one buys a branch against a single build. The counting
152
152
  below is the net for drift; the rule above is the only thing standing between a
153
153
  repair and the next repair.
154
154
 
155
+ ## The many-module surface
156
+
157
+ Through 2.1.241 the bundle was one module — the entrypoint carried the whole app
158
+ — and every matcher searched that one buffer. 2.1.242 code-split it: the
159
+ entrypoint became a ~20 KB argv shim that lazily imports the app across ~1,300
160
+ `chunk-*.js` modules ([INTERNALS](INTERNALS.md#the-21242-split-and-the-patchable-surface)).
161
+ The anchors did not move — they scattered. So `js.Source` **spans every
162
+ JavaScript module the container declares** (`Blob.js_modules`, discovered off the
163
+ entrypoint's own loader), and the one-module world is exactly the many-module
164
+ world with one module: `find`/`literals`/`count` sweep every module, `apply`
165
+ routes each edit to the module its node came from, and a patch says
166
+ `source.find(name)` / `source.apply(edits)` unchanged. This is representation
167
+ change of the crudest kind — the same code in more files — so it *moved* no
168
+ invariant and abolished none; the surface widened, the rules held. Three things
169
+ the move made explicit, each a rule the single-module world could leave unsaid
170
+ because the module was the bundle:
171
+
172
+ - **A node carries its module; an offset does not.** An `Edit` is built from a
173
+ node, and the node's program root (`node.id`, distinct across parses) names the
174
+ module the edit belongs to, so a batch spanning a dozen chunks routes each
175
+ splice home and reparses only the modules it touched. It is why dedup keys are
176
+ the node's `id`, never its `start_byte`: a minified offset repeats in a
177
+ thousand modules, and two different sites at the same local offset are two
178
+ sites, not one to skip.
179
+
180
+ - **A minified local is scoped to its module.** Resolving one — a hoisted
181
+ `agentType:uu` constant read through its value — searches *its own* module
182
+ (`Source.find_local`), never the bundle, where `uu` binds a string in a dozen
183
+ unrelated chunks and `js.only` would rightly refuse to choose. Before the split
184
+ the module *was* the bundle, so a bundle-wide `find` was a module-wide one; the
185
+ two only came apart when the app was dealt across files. The same lesson the
186
+ playbook already states for identifiers within a build — "a name is a spelling
187
+ until its scope is said" — now reaches the module boundary too. A scope search
188
+ that climbs outward (`live-thinking`'s state and memo resolution) reads the
189
+ module wrapper by what it *is* — the pre-split monolith's whole-body IIFE is a
190
+ module of one top-level statement; a split module's `program` is dozens, and
191
+ its top-level components are real scopes to search — never by a fixed position,
192
+ which read every split component as the un-searchable wrapper and found nothing.
193
+
194
+ - **The gate reads code, not linkage.** tree-sitter's JavaScript grammar does not
195
+ model a reserved word as an import/export alias (`export{x as if}`,
196
+ `import{if as a}` — legal ES2015, which the split minifier emits when its
197
+ two-letter alias generator lands on `if`/`in`/`do`), so it plants a localized
198
+ `ERROR` inside the clause. patch-cc never locates in or edits module linkage —
199
+ every anchor is executable code — so `Source.defect` reads *past* an error
200
+ confined to an import/export statement and returns the first one that is not,
201
+ while a splice that broke real code still lands outside linkage and is still
202
+ caught. It is the gate's scope stated precisely, not a tolerance bolted on: the
203
+ eight such modules on 2.1.243 parse clean for every purpose the tool has.
204
+
155
205
  ## Discovery instead of hardcoding
156
206
 
157
207
  Anything the binary can enumerate for us, it does:
@@ -313,7 +363,9 @@ re-deriving it.
313
363
 
314
364
  Both numbers above are counts, and a count cannot tell a rewrite that landed
315
365
  from a rewrite that landed *one prop-name to the left*. So the bundle is parsed,
316
- and any `ERROR` or `MISSING` node aborts (`src/patch_cc/js.py`).
366
+ and any `ERROR` or `MISSING` node **outside module linkage** aborts
367
+ (`src/patch_cc/js.py`; the linkage read-past is
368
+ [above](#the-many-module-surface)).
317
369
 
318
370
  The parse is not a separate pass any more: locating already needs it, and an
319
371
  edit costs one incremental reparse — ~75 ms on a 25 MB bundle against ~3 s for a
@@ -493,6 +545,10 @@ that broke a patch along with every note.
493
545
  patch-cc extract ~/.local/share/claude/versions/<ver> > clean.js
494
546
  ```
495
547
 
548
+ Since 2.1.242 that is every JavaScript module concatenated, each behind a
549
+ `// ==== patch-cc module <n> ====` header, so `rg` still works over one file
550
+ and the header says which chunk a hit lives in.
551
+
496
552
  2. Run `patch-cc doctor`. Note which patch dropped to `candidates == 0`, or —
497
553
  for `live-thinking` — which sub-step.
498
554
 
@@ -856,11 +912,17 @@ for you. Each entry: what it changes, the stable anchor, and where it lives.
856
912
  than carried over from whichever renderer the first step happened to see
857
913
  last: a second component with the same four props donated its local to the
858
914
  first one's memo, spliced an identifier that component cannot see, and
859
- reported both steps green. The memo itself is found by what it computes (a
860
- `useMemo` over a `flatMap` that wraps each streaming block as a message),
861
- which is what a claim about the *text* around the block (`.contentBlock]}`)
862
- only approximated: one sibling property beside `content` read as the whole
863
- computation being gone.
915
+ reported both steps green. The memo itself is found by what it computes — an
916
+ arrow whose whole body is a `flatMap` that wraps each streaming block as a
917
+ message, and the call that takes that arrow never by the memo hook's name.
918
+ The hook is one more minified spelling: the monolith reaches it as
919
+ `<React>.useMemo(...)` (the property name survives a member read), but a
920
+ code-split module imports it under a local (`import{useMemo as te}` → `te(...)`),
921
+ so keying on `useMemo` read a build that memoizes exactly the same way as one
922
+ with no memo at all, and the rewrite reuses the memo's *own* callee rather than
923
+ re-spelling `.useMemo`. What a claim about the *text* around the block
924
+ (`.contentBlock]}`) only approximated is the computation itself: one sibling
925
+ property beside `content` read as the whole thing being gone.
864
926
 
865
927
  **`display-mode`** defaults the request's thinking display to `"summarized"`;
866
928
  without it the API only streams summary text when the `showThinkingSummaries`
@@ -21,11 +21,13 @@ matches means the file changed under you, not that a number moved.
21
21
 
22
22
  ## On disk now
23
23
 
24
- The whole published span `2.1.210` → `2.1.236` — 2.1.230 was never published —
25
- one pristine binary per version, 26 in all. Two pairs share a byte count
26
- (`2.1.225`/`2.1.226`, `2.1.229`/`2.1.231`) and are still distinct binaries,
27
- which is why the identity column is the hash (of the whole file,
28
- `sha256sum <version>.orig`) and never the size:
24
+ The whole published span `2.1.210` → `2.1.243` — 2.1.230 was never published —
25
+ one pristine binary per version, 33 in all. It straddles the **2.1.242 code
26
+ split** (INTERNALS.md): `2.1.242`/`2.1.243` are the first many-module builds, and
27
+ they jump ~35 MB over `2.1.241` for it. Several pairs share a byte count
28
+ (`2.1.225`/`2.1.226`, `2.1.229`/`2.1.231`, `2.1.239`–`2.1.241`, `2.1.242`/`2.1.243`)
29
+ and are still distinct binaries, which is why the identity column is the hash (of
30
+ the whole file, `sha256sum <version>.orig`) and never the size:
29
31
 
30
32
  | version | size | sha256 |
31
33
  |---|---|---|
@@ -55,10 +57,17 @@ which is why the identity column is the hash (of the whole file,
55
57
  | `2.1.234` | 328 MB | `3473601ea695d5bf769c5b202844d4cb4fbf723ae995450fcb6973204775c84a` |
56
58
  | `2.1.235` | 331 MB | `bfcf0ae2dbf94b2b6a106074aabf3938b9a10889c3b678e4cb5a00c03274d5d5` |
57
59
  | `2.1.236` | 335 MB | `6c8818fa22187aa555c242be4abbacc44d6b71a32ac9631ee7b2b5d12f51f752` |
60
+ | `2.1.237` | 335 MB | `73975167f0108693cf6fd6614994781657ebb8456ebef5d247458734abfb3916` |
61
+ | `2.1.238` | 339 MB | `0933b286cf94e1b2504b35ac165ab76b8f822735d53371c56393988c23040d58` |
62
+ | `2.1.239` | 343 MB | `7de1b1576e2e0be73ce91c2b4dedf16a41058ea633b957a36fdc6044ddfc0f3c` |
63
+ | `2.1.240` | 343 MB | `1386169da77de19a655f07a86ab80f5775983a50eb0c9c27a7daf16e7320322d` |
64
+ | `2.1.241` | 343 MB | `0771bd866cff82b76581fc0499f6529e1a36845078f144f8c81dccb3bc7037b8` |
65
+ | `2.1.242` | 378 MB | `528ef039aa7d64d7b3fbc06925132755a516b4dcaad784cf0b51fe03167360d4` |
66
+ | `2.1.243` | 378 MB | `4b0dafeedd0b469c41988e200036fd773e7553ba960349c9f02a82c6d1f2ba27` |
58
67
 
59
68
  This set covers the span the playbook's tree-move measurements were taken over
60
- (`2.1.210` → `2.1.233`), so those numbers are re-checkable here rather than
61
- historical.
69
+ (`2.1.210` → `2.1.233`) and the 2.1.242 split, so both are re-checkable here
70
+ rather than historical.
62
71
 
63
72
  ## Rebuild or extend it
64
73
 
@@ -1,3 +1,3 @@
1
1
  """patch-cc: an interactive patcher for the Claude Code native binary."""
2
2
 
3
- __version__ = "0.3.3"
3
+ __version__ = "0.4.0"
@@ -24,6 +24,11 @@ from .errors import BunError
24
24
  TRAILER = b"\n---- Bun! ----\n"
25
25
  OFFSETS_SIZE = 32
26
26
 
27
+ #: Index of the ``loader`` byte in a module record's four trailing flags
28
+ #: (``encoding, loader, module_format, side``). It is how Bun decides whether a
29
+ #: module is JavaScript it compiles or an asset it hands over as bytes.
30
+ LOADER = 1
31
+
27
32
  #: Module record field order. Old Bun (<1.3.7) stops after ``bytecode``.
28
33
  FIELDS_NEW = (
29
34
  "name",
@@ -86,18 +91,40 @@ class Blob:
86
91
  """
87
92
  return self.modules[self.entry_point_id]
88
93
 
89
- def entry_source(self) -> bytes:
90
- return self.payload(self.entry_module().ranges["contents"])
94
+ def js_modules(self) -> list[Module]:
95
+ """Every module Bun loads the way it loads the entrypoint: the JS the
96
+ app is made of.
97
+
98
+ Since 2.1.242 the entrypoint is a ~20 KB argv shim that lazily imports
99
+ the app across ~1,300 ``chunk-*.js`` modules; before it, the entrypoint
100
+ *was* the whole app. Both are one question -- which modules carry the
101
+ JavaScript we patch -- and the loader byte answers it: a module's
102
+ ``loader`` (the second trailing flag) says how Bun reads it, and the
103
+ entry is by definition the JS Bun runs, so its loader *is* the JS loader.
104
+ Read off the artifact, exactly as :meth:`entry_module` reads the
105
+ entrypoint -- never hardcoded to a value, and never by name -- so a build
106
+ that renumbers the loader still answers correctly. The assets (native
107
+ addons, the bundled ``mermaid``/``hljs`` minified files, the HTML
108
+ template) carry other loaders and are excluded: they are opaque bytes to
109
+ Bun and would not parse as our JavaScript.
110
+
111
+ Pre-split, this is the one-element list ``[entry_module()]`` -- the
112
+ monolith -- so the single-module world is exactly the many-module world
113
+ with one module, and every layer above flows through the same code.
114
+ """
115
+ loader = self.entry_module().trailing[LOADER]
116
+ return [m for m in self.modules if m.trailing[LOADER] == loader]
91
117
 
92
118
  def bytecode_size(self) -> int:
93
- """Bytes of precompiled bytecode on the entrypoint, for either layout.
119
+ """Total precompiled Bun bytecode across every module, for either layout.
94
120
 
95
121
  ``bytecode`` is the fourth pair, so *both* record formats carry it --
96
- ``FIELDS_OLD`` is the first four of ``FIELDS_NEW``. Reporting zero for the
97
- 36-byte layout said "already stripped" about a module that had 154 MB of
98
- it, which is the one thing ``status`` reads this for.
122
+ ``FIELDS_OLD`` is the first four of ``FIELDS_NEW``. Before 2.1.242 only
123
+ the entrypoint carried any; the code-split builds carry it on every
124
+ chunk, so the figure ``status`` reports and the write reclaims is the sum,
125
+ not one module's.
99
126
  """
100
- return self.entry_module().ranges["bytecode"][1]
127
+ return sum(m.ranges["bytecode"][1] for m in self.modules)
101
128
 
102
129
 
103
130
  def _detect_struct_size(modules_len: int) -> int:
@@ -192,20 +219,29 @@ def parse(data: bytes) -> Blob:
192
219
  )
193
220
 
194
221
 
195
- def rebuild(blob: Blob, source: bytes, *, drop_bytecode: bool = True) -> bytes:
196
- """Return a new blob with the entrypoint's source replaced.
197
-
198
- Payloads are re-emitted in their original file order so the result stays as
199
- close to the input layout as possible.
200
-
201
- ``drop_bytecode`` removes the entrypoint's precompiled Bun bytecode. Editing
202
- the source invalidates that bytecode anyway -- Bun recompiles from source --
203
- so keeping it costs more than half the binary for no benefit. What that is in
204
- megabytes is a fact about the build, and :meth:`Blob.bytecode_size` reads it;
205
- docs/INTERNALS.md has the measurements.
222
+ def rebuild(
223
+ blob: Blob, sources: dict[int, bytes], *, drop_bytecode: bool = True
224
+ ) -> bytes:
225
+ """Return a new blob with the given modules' source replaced.
226
+
227
+ ``sources`` maps a module index to its new ``contents``; every other
228
+ module's bytes are re-emitted unchanged. Payloads keep their original file
229
+ order so the result stays as close to the input layout as possible. One
230
+ edited module or a thousand is the same code -- the pre-split monolith is
231
+ just ``{entry_index: new_bytes}``.
232
+
233
+ ``drop_bytecode`` removes the precompiled Bun bytecode of exactly the
234
+ modules whose source changed. Editing a module's source invalidates its
235
+ bytecode -- Bun recompiles that module from source -- so keeping it would
236
+ run the recompile cost *and* the megabytes; dropping it reclaims the space
237
+ and, more importantly, guarantees our edits are what runs
238
+ (docs/INTERNALS.md). A module we did not touch keeps its bytecode and its
239
+ fast start. The set actually changed is returned by :func:`changed_modules`;
240
+ here we drop for every module handed new bytes, since a caller only passes
241
+ bytes it means to replace.
206
242
  """
207
- entry = blob.entry_module()
208
243
  fields = blob.fields
244
+ changed = set(sources)
209
245
 
210
246
  # Every payload, in the order it appears in the source arena.
211
247
  placed: list[
@@ -222,15 +258,15 @@ def rebuild(blob: Blob, source: bytes, *, drop_bytecode: bool = True) -> bytes:
222
258
  new_ranges: dict[tuple[int, str], tuple[int, int]] = {}
223
259
  prev_end = 0
224
260
  for off, length, mod_index, field in placed:
225
- is_entry = mod_index == entry.index
226
- if is_entry and field == "bytecode" and drop_bytecode:
261
+ edited = mod_index in changed
262
+ if edited and field == "bytecode" and drop_bytecode:
227
263
  new_ranges[(mod_index, field)] = (0, 0)
228
264
  prev_end = off + length
229
265
  continue
230
266
 
231
267
  payload = (
232
- source
233
- if (is_entry and field == "contents")
268
+ sources[mod_index]
269
+ if (edited and field == "contents")
234
270
  else blob.data[off : off + length]
235
271
  )
236
272
  # Preserve the 1-byte separators Bun emits between payloads.
@@ -268,16 +304,33 @@ def rebuild(blob: Blob, source: bytes, *, drop_bytecode: bool = True) -> bytes:
268
304
  struct.pack_into("<I", out, offsets_at + 28, blob.flags)
269
305
 
270
306
  if drop_bytecode:
271
- # The entry now carries no bytecode -- whether we dropped a real payload
272
- # or it had none to begin with. Reading the outcome with a ``(0, 0)``
273
- # default is what lets the had-none case flow through the same check
274
- # instead of raising ``KeyError`` past both CLI handlers: a field the
275
- # ``placed`` loop never saw a length for is absent from ``new_ranges``,
276
- # and absent *is* stripped.
277
- assert new_ranges.get((entry.index, "bytecode"), (0, 0)) == (0, 0)
307
+ # Each edited module now carries no bytecode -- whether we dropped a real
308
+ # payload or it had none to begin with. Reading the outcome with a
309
+ # ``(0, 0)`` default is what lets the had-none case flow through the same
310
+ # check instead of raising ``KeyError``: a field the ``placed`` loop never
311
+ # saw a length for is absent from ``new_ranges``, and absent *is*
312
+ # stripped.
313
+ for index in changed:
314
+ assert new_ranges.get((index, "bytecode"), (0, 0)) == (0, 0)
278
315
  return bytes(out)
279
316
 
280
317
 
318
+ def changed_modules(blob: Blob, sources: dict[int, bytes]) -> dict[int, bytes]:
319
+ """The subset of ``sources`` whose bytes actually differ from the blob.
320
+
321
+ A module the patches parsed but left byte-identical must not have its
322
+ bytecode dropped: that would trade a working fast-start module for a slow
323
+ recompile of code that never changed. So the write reclaims bytecode for the
324
+ modules that moved and no others, and the size tripwire measures against that
325
+ same set.
326
+ """
327
+ return {
328
+ index: data
329
+ for index, data in sources.items()
330
+ if data != blob.payload(blob.modules[index].ranges["contents"])
331
+ }
332
+
333
+
281
334
  def unwrap_section(section: bytes) -> tuple[bytes, int]:
282
335
  """Strip the length prefix a container section puts in front of the blob.
283
336