patch-cc 0.3.2__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 (46) hide show
  1. patch_cc-0.4.0/.github/workflows/release-watch.yml +164 -0
  2. {patch_cc-0.3.2 → patch_cc-0.4.0}/PKG-INFO +11 -9
  3. {patch_cc-0.3.2 → patch_cc-0.4.0}/README.md +10 -8
  4. {patch_cc-0.3.2 → patch_cc-0.4.0}/docs/INTERNALS.md +85 -45
  5. {patch_cc-0.3.2 → patch_cc-0.4.0}/docs/PLAYBOOK.md +99 -11
  6. patch_cc-0.4.0/docs/corpus.md +97 -0
  7. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/__init__.py +1 -1
  8. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/bun/blob.py +83 -30
  9. patch_cc-0.4.0/src/patch_cc/bun/container.py +208 -0
  10. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/cli.py +11 -1
  11. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/js.py +388 -135
  12. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/patcher.py +21 -22
  13. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/patches/agents.py +9 -3
  14. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/patches/chrome.py +19 -10
  15. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/patches/codex.py +1 -3
  16. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/patches/streaming.py +319 -78
  17. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/patches/thinking.py +4 -4
  18. patch_cc-0.3.2/docs/corpus.md +0 -86
  19. patch_cc-0.3.2/src/patch_cc/bun/container.py +0 -183
  20. {patch_cc-0.3.2 → patch_cc-0.4.0}/.github/workflows/ci.yml +0 -0
  21. {patch_cc-0.3.2 → patch_cc-0.4.0}/.github/workflows/release.yml +0 -0
  22. {patch_cc-0.3.2 → patch_cc-0.4.0}/.gitignore +0 -0
  23. {patch_cc-0.3.2 → patch_cc-0.4.0}/AGENTS.md +0 -0
  24. {patch_cc-0.3.2 → patch_cc-0.4.0}/CLAUDE.md +0 -0
  25. {patch_cc-0.3.2 → patch_cc-0.4.0}/LICENSE +0 -0
  26. {patch_cc-0.3.2 → patch_cc-0.4.0}/docs/CONDUCT.md +0 -0
  27. {patch_cc-0.3.2 → patch_cc-0.4.0}/docs/demo.gif +0 -0
  28. {patch_cc-0.3.2 → patch_cc-0.4.0}/pyproject.toml +0 -0
  29. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/bun/__init__.py +0 -0
  30. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/bun/elf.py +0 -0
  31. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/bun/errors.py +0 -0
  32. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/bun/macho.py +0 -0
  33. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/cache.py +0 -0
  34. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/codex/__init__.py +0 -0
  35. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/codex/gateway.py +0 -0
  36. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/codex/models.py +0 -0
  37. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/codex/oauth.py +0 -0
  38. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/codex/translate.py +0 -0
  39. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/doctor.py +0 -0
  40. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/locate.py +0 -0
  41. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/menu.py +0 -0
  42. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/patches/__init__.py +0 -0
  43. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/patches/base.py +0 -0
  44. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/patches/output.py +0 -0
  45. {patch_cc-0.3.2 → patch_cc-0.4.0}/src/patch_cc/ui.py +0 -0
  46. {patch_cc-0.3.2 → patch_cc-0.4.0}/uv.lock +0 -0
@@ -0,0 +1,164 @@
1
+ name: Release watch
2
+
3
+ # Fires the "patch-cc release watch" cloud routine the moment Anthropic ships a
4
+ # new Claude Code build, instead of leaving it to the routine's own daily cron.
5
+ #
6
+ # The routine already knows how to decide whether anything moved -- it compares
7
+ # the upstream version and the checkout HEAD against the ledger in issue #1 and
8
+ # exits silently when neither did. Its schedule was never the interesting part;
9
+ # it was just a poll, once a day, which meant a release that broke a patch could
10
+ # sit unnoticed for up to 24 hours. This workflow moves the poll here, where a
11
+ # tick is one curl instead of a cloud session, and only wakes the routine when
12
+ # the version actually changes.
13
+ #
14
+ # The routine keeps its daily schedule as a backstop: if this workflow breaks or
15
+ # GitHub skips the schedule, the watch degrades to its old once-a-day behaviour
16
+ # rather than going silent. That is why a failed poll below is a warning and not
17
+ # a red run -- a flaky fetch must not page anyone.
18
+ #
19
+ # SETUP (one time, by hand -- there is no API for token creation):
20
+ # claude.ai/code/routines -> "patch-cc release watch" -> pencil icon
21
+ # -> Add another trigger -> API -> Generate token (shown once)
22
+ # -> store it as the repo secret ROUTINE_FIRE_TOKEN.
23
+
24
+ on:
25
+ schedule:
26
+ # Every 10 minutes. GitHub delays scheduled runs under load, so read this as
27
+ # "within ~10-25 min of a release", not as a guarantee.
28
+ - cron: "*/10 * * * *"
29
+ workflow_dispatch:
30
+
31
+ concurrency:
32
+ group: release-watch
33
+ cancel-in-progress: false
34
+
35
+ permissions:
36
+ contents: read
37
+ issues: read
38
+
39
+ env:
40
+ # The channel file the Claude Code installer itself reads -- the same source
41
+ # the routine treats as authoritative, so the two never disagree about what
42
+ # "the current release" means.
43
+ CHANNEL_URL: https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest
44
+ # Not a secret: firing needs the bearer token, and that is one.
45
+ ROUTINE_ID: trig_01WrgxdCZahJEHD3NNYcetqS
46
+ LEDGER_ISSUE: "1"
47
+
48
+ jobs:
49
+ watch:
50
+ runs-on: ubuntu-latest
51
+ steps:
52
+ - name: Read the release channel
53
+ id: upstream
54
+ run: |
55
+ set -uo pipefail
56
+ if ! v=$(curl -fsSL --max-time 30 --retry 3 --retry-delay 5 "$CHANNEL_URL"); then
57
+ echo "::warning::could not reach the release channel; skipping this tick"
58
+ echo "skip=1" >> "$GITHUB_OUTPUT"
59
+ exit 0
60
+ fi
61
+ v=$(printf '%s' "$v" | tr -d '[:space:]')
62
+ case "$v" in
63
+ [0-9]*.[0-9]*.[0-9]*) ;;
64
+ *)
65
+ echo "::warning::channel returned something that is not a version: '$v'"
66
+ echo "skip=1" >> "$GITHUB_OUTPUT"
67
+ exit 0
68
+ ;;
69
+ esac
70
+ echo "version=$v" >> "$GITHUB_OUTPUT"
71
+ echo "upstream: $v"
72
+
73
+ # First guard: did an earlier tick already fire for this version? The
74
+ # routine takes several minutes to run (it downloads a ~275 MB bundle), so
75
+ # without this every tick during that window would fire it again.
76
+ - name: Already fired for this version?
77
+ id: marker
78
+ if: steps.upstream.outputs.skip != '1'
79
+ uses: actions/cache/restore@v4
80
+ with:
81
+ path: .fired
82
+ key: fired-${{ steps.upstream.outputs.version }}
83
+ lookup-only: true
84
+
85
+ # Second guard, and the authoritative one: the ledger records the version
86
+ # the routine has actually finished processing. This is what catches the
87
+ # case where the cache entry was evicted but the work is long done.
88
+ - name: Read the ledger's last-version
89
+ id: ledger
90
+ if: steps.upstream.outputs.skip != '1' && steps.marker.outputs.cache-hit != 'true'
91
+ env:
92
+ GH_TOKEN: ${{ github.token }}
93
+ run: |
94
+ set -uo pipefail
95
+ if ! body=$(gh issue view "$LEDGER_ISSUE" --repo "$GITHUB_REPOSITORY" --json body -q .body); then
96
+ echo "::warning::could not read the ledger; skipping this tick rather than guessing"
97
+ echo "skip=1" >> "$GITHUB_OUTPUT"
98
+ exit 0
99
+ fi
100
+ last=$(printf '%s\n' "$body" | sed -n 's/^last-version:[[:space:]]*//p' | head -1 | tr -d '[:space:]')
101
+ echo "last=$last" >> "$GITHUB_OUTPUT"
102
+ echo "ledger: $last"
103
+
104
+ - name: Fire the routine
105
+ id: fire
106
+ if: >-
107
+ steps.upstream.outputs.skip != '1' &&
108
+ steps.marker.outputs.cache-hit != 'true' &&
109
+ steps.ledger.outputs.skip != '1' &&
110
+ steps.ledger.outputs.last != steps.upstream.outputs.version
111
+ env:
112
+ TOKEN: ${{ secrets.ROUTINE_FIRE_TOKEN }}
113
+ VERSION: ${{ steps.upstream.outputs.version }}
114
+ LAST: ${{ steps.ledger.outputs.last }}
115
+ run: |
116
+ set -uo pipefail
117
+ if [ -z "${TOKEN:-}" ]; then
118
+ echo "::error::ROUTINE_FIRE_TOKEN is not set -- see the setup note at the top of this file"
119
+ exit 1
120
+ fi
121
+ body=$(printf 'Claude Code %s is on the release channel; the ledger last recorded %s. This line is a hint for the run log only -- re-derive the version yourself from the channel, per your step 2.' "$VERSION" "${LAST:-(none)}")
122
+ payload=$(jq -nc --arg t "$body" '{text: $t}')
123
+ out=$(mktemp)
124
+ code=$(curl -sS -o "$out" -w '%{http_code}' -X POST \
125
+ "https://api.anthropic.com/v1/claude_code/routines/$ROUTINE_ID/fire" \
126
+ -H "Authorization: Bearer $TOKEN" \
127
+ -H "anthropic-version: 2023-06-01" \
128
+ -H "anthropic-beta: experimental-cc-routine-2026-04-01" \
129
+ -H "Content-Type: application/json" \
130
+ -d "$payload") || code=000
131
+ echo "HTTP $code"
132
+ cat "$out"
133
+ echo
134
+ if [ "$code" != "200" ]; then
135
+ # Deliberately not marking the version as fired, so the next tick
136
+ # retries. A 429 here just means the daily run cap was reached.
137
+ echo "::error::fire failed with HTTP $code"
138
+ exit 1
139
+ fi
140
+ echo "url=$(jq -r '.claude_code_session_url // empty' "$out")" >> "$GITHUB_OUTPUT"
141
+ : > .fired
142
+
143
+ # Only reached on a 200, so the marker never claims a fire that failed.
144
+ - name: Mark this version as fired
145
+ if: steps.fire.outcome == 'success'
146
+ uses: actions/cache/save@v4
147
+ with:
148
+ path: .fired
149
+ key: fired-${{ steps.upstream.outputs.version }}
150
+
151
+ - name: Summary
152
+ if: always()
153
+ run: |
154
+ {
155
+ echo "### Release watch"
156
+ echo
157
+ echo "- upstream: \`${{ steps.upstream.outputs.version || 'unreadable' }}\`"
158
+ echo "- ledger last-version: \`${{ steps.ledger.outputs.last || 'not read' }}\`"
159
+ if [ -n "${{ steps.fire.outputs.url }}" ]; then
160
+ echo "- **fired the routine** -> ${{ steps.fire.outputs.url }}"
161
+ else
162
+ echo "- no fire (nothing moved, or already fired for this version)"
163
+ fi
164
+ } >> "$GITHUB_STEP_SUMMARY"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: patch-cc
3
- Version: 0.3.2
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
@@ -455,7 +507,11 @@ Each sub-step records its own `candidates`/`applied`:
455
507
  reducer whose setter threading applied while every arm had drifted once
456
508
  reported hits and streamed nothing.
457
509
  - **`final-summary`**, optional: redacted thinking in the closing summary is a
458
- refinement, not the feature.
510
+ refinement, not the feature. Its guard is whichever enclosing `if` *tests the
511
+ block* — 2.1.236 nested an experiment gate between the test and the summary
512
+ it guards, and "the nearest `if`" read a shape that had merely moved as one
513
+ that was gone. The climb is bounded by the function that declares the block,
514
+ because a receiver's name is only a spelling until its scope is said.
459
515
 
460
516
  An *optional* sub-step that finds nothing is reported as absent, not broken —
461
517
  it is just a shape this build doesn't have. A sub-step that finds a shape but
@@ -489,6 +545,10 @@ that broke a patch along with every note.
489
545
  patch-cc extract ~/.local/share/claude/versions/<ver> > clean.js
490
546
  ```
491
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
+
492
552
  2. Run `patch-cc doctor`. Note which patch dropped to `candidates == 0`, or —
493
553
  for `live-thinking` — which sub-step.
494
554
 
@@ -788,6 +848,28 @@ for you. Each entry: what it changes, the stable anchor, and where it lives.
788
848
  *initialised* to is deliberately not asked: `useState(null)` is every
789
849
  build's spelling and `useState(void 0)` would be the same state, while the
790
850
  setter is the identity that matters.
851
+ - **The state has two homes, and the handed setter names both.** Through
852
+ 2.1.235 the scope that hands `onStreamingThinking` declared the state
853
+ itself, and the state is the array pattern binding the handed setter —
854
+ `useState`'s pair. 2.1.236 moved it into an external stream store
855
+ (`subscribe`/`getSnapshot`/`_publish` — the `useSyncExternalStore` shape)
856
+ and hands `<store>.setStreamingThinking` instead; the same scope reads the
857
+ store back by destructuring the hook call it hands the store to
858
+ (`{streamingToolUses:…}=useX(<store>)`), so the state is that pattern's own
859
+ `streamingThinking` binding — upstream's the day it takes one, the goal
860
+ achieved, and until then ours, inserted at the front of the pattern. The
861
+ pattern is proven the snapshot read by the store expression itself: the
862
+ call's *only* argument is the very expression the setter was read off, one
863
+ answer or none (`js.only`). Sole argument is deliberate — a second is a
864
+ selector whose result is no longer the snapshot, and extending a pattern of
865
+ unknowable provenance binds `undefined` with every count green. The
866
+ insertion also pays for a witness the way `thinking-summaries` does: the
867
+ field it binds must still be named by the bundle's own objects (the store's
868
+ snapshot initialiser, its publish call), so a store that renames the field
869
+ reads as the step reporting the store rather than threading `undefined`.
870
+ Two things come free with the store: its setter takes functional updaters —
871
+ React's own contract, which the reducer splices already speak — and it
872
+ hides a finished block itself after 30 s, upstream's own linger.
791
873
 
792
874
  A render was once selected by *position* — the observation that the real sites
793
875
  fall after the state's `useState` declaration. That is worth recording as a
@@ -830,11 +912,17 @@ for you. Each entry: what it changes, the stable anchor, and where it lives.
830
912
  than carried over from whichever renderer the first step happened to see
831
913
  last: a second component with the same four props donated its local to the
832
914
  first one's memo, spliced an identifier that component cannot see, and
833
- reported both steps green. The memo itself is found by what it computes (a
834
- `useMemo` over a `flatMap` that wraps each streaming block as a message),
835
- which is what a claim about the *text* around the block (`.contentBlock]}`)
836
- only approximated: one sibling property beside `content` read as the whole
837
- 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.
838
926
 
839
927
  **`display-mode`** defaults the request's thinking display to `"summarized"`;
840
928
  without it the API only streams summary text when the `showThinkingSummaries`
@@ -1245,10 +1333,10 @@ enumerated with their hashes in [corpus.md](corpus.md). Each removed matcher hit
1245
1333
  can still be running; each was carried as "kept for older builds", which is a
1246
1334
  claim the corpus disproves. The verbatim matchers were re-run from `HEAD` over
1247
1335
  the whole corpus after the move, and they are still zero everywhere. The
1248
- measurements this section quotes were taken during the move across a wider span
1249
- (2.1.210 → 2.1.233; 2.1.230 was never published), some of whose early builds
1250
- predate the current backup set `corpus.md` is the reproducible subset on disk
1251
- now, and re-running `doctor` over it is one command (below).
1336
+ measurements this section quotes were taken during the move across
1337
+ 2.1.210 → 2.1.233 (2.1.230 was never published) a span the corpus has since
1338
+ grown to hold in full, so they are re-checkable rather than historical, and
1339
+ re-running `doctor` over it is one command (below).
1252
1340
 
1253
1341
  - **`live-thinking` / `reducer-legacy`** — the pre-2.1.138 reducer with
1254
1342
  positional parameters. Its removal collapsed the `reducer` variant *group*