patch-cc 0.3.2__tar.gz → 0.3.3__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 (44) hide show
  1. patch_cc-0.3.3/.github/workflows/release-watch.yml +164 -0
  2. {patch_cc-0.3.2 → patch_cc-0.3.3}/PKG-INFO +1 -1
  3. {patch_cc-0.3.2 → patch_cc-0.3.3}/docs/PLAYBOOK.md +31 -5
  4. {patch_cc-0.3.2 → patch_cc-0.3.3}/docs/corpus.md +28 -26
  5. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/__init__.py +1 -1
  6. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/patches/streaming.py +137 -44
  7. {patch_cc-0.3.2 → patch_cc-0.3.3}/.github/workflows/ci.yml +0 -0
  8. {patch_cc-0.3.2 → patch_cc-0.3.3}/.github/workflows/release.yml +0 -0
  9. {patch_cc-0.3.2 → patch_cc-0.3.3}/.gitignore +0 -0
  10. {patch_cc-0.3.2 → patch_cc-0.3.3}/AGENTS.md +0 -0
  11. {patch_cc-0.3.2 → patch_cc-0.3.3}/CLAUDE.md +0 -0
  12. {patch_cc-0.3.2 → patch_cc-0.3.3}/LICENSE +0 -0
  13. {patch_cc-0.3.2 → patch_cc-0.3.3}/README.md +0 -0
  14. {patch_cc-0.3.2 → patch_cc-0.3.3}/docs/CONDUCT.md +0 -0
  15. {patch_cc-0.3.2 → patch_cc-0.3.3}/docs/INTERNALS.md +0 -0
  16. {patch_cc-0.3.2 → patch_cc-0.3.3}/docs/demo.gif +0 -0
  17. {patch_cc-0.3.2 → patch_cc-0.3.3}/pyproject.toml +0 -0
  18. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/bun/__init__.py +0 -0
  19. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/bun/blob.py +0 -0
  20. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/bun/container.py +0 -0
  21. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/bun/elf.py +0 -0
  22. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/bun/errors.py +0 -0
  23. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/bun/macho.py +0 -0
  24. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/cache.py +0 -0
  25. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/cli.py +0 -0
  26. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/codex/__init__.py +0 -0
  27. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/codex/gateway.py +0 -0
  28. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/codex/models.py +0 -0
  29. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/codex/oauth.py +0 -0
  30. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/codex/translate.py +0 -0
  31. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/doctor.py +0 -0
  32. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/js.py +0 -0
  33. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/locate.py +0 -0
  34. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/menu.py +0 -0
  35. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/patcher.py +0 -0
  36. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/patches/__init__.py +0 -0
  37. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/patches/agents.py +0 -0
  38. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/patches/base.py +0 -0
  39. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/patches/chrome.py +0 -0
  40. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/patches/codex.py +0 -0
  41. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/patches/output.py +0 -0
  42. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/patches/thinking.py +0 -0
  43. {patch_cc-0.3.2 → patch_cc-0.3.3}/src/patch_cc/ui.py +0 -0
  44. {patch_cc-0.3.2 → patch_cc-0.3.3}/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.3.3
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
@@ -455,7 +455,11 @@ Each sub-step records its own `candidates`/`applied`:
455
455
  reducer whose setter threading applied while every arm had drifted once
456
456
  reported hits and streamed nothing.
457
457
  - **`final-summary`**, optional: redacted thinking in the closing summary is a
458
- refinement, not the feature.
458
+ refinement, not the feature. Its guard is whichever enclosing `if` *tests the
459
+ block* — 2.1.236 nested an experiment gate between the test and the summary
460
+ it guards, and "the nearest `if`" read a shape that had merely moved as one
461
+ that was gone. The climb is bounded by the function that declares the block,
462
+ because a receiver's name is only a spelling until its scope is said.
459
463
 
460
464
  An *optional* sub-step that finds nothing is reported as absent, not broken —
461
465
  it is just a shape this build doesn't have. A sub-step that finds a shape but
@@ -788,6 +792,28 @@ for you. Each entry: what it changes, the stable anchor, and where it lives.
788
792
  *initialised* to is deliberately not asked: `useState(null)` is every
789
793
  build's spelling and `useState(void 0)` would be the same state, while the
790
794
  setter is the identity that matters.
795
+ - **The state has two homes, and the handed setter names both.** Through
796
+ 2.1.235 the scope that hands `onStreamingThinking` declared the state
797
+ itself, and the state is the array pattern binding the handed setter —
798
+ `useState`'s pair. 2.1.236 moved it into an external stream store
799
+ (`subscribe`/`getSnapshot`/`_publish` — the `useSyncExternalStore` shape)
800
+ and hands `<store>.setStreamingThinking` instead; the same scope reads the
801
+ store back by destructuring the hook call it hands the store to
802
+ (`{streamingToolUses:…}=useX(<store>)`), so the state is that pattern's own
803
+ `streamingThinking` binding — upstream's the day it takes one, the goal
804
+ achieved, and until then ours, inserted at the front of the pattern. The
805
+ pattern is proven the snapshot read by the store expression itself: the
806
+ call's *only* argument is the very expression the setter was read off, one
807
+ answer or none (`js.only`). Sole argument is deliberate — a second is a
808
+ selector whose result is no longer the snapshot, and extending a pattern of
809
+ unknowable provenance binds `undefined` with every count green. The
810
+ insertion also pays for a witness the way `thinking-summaries` does: the
811
+ field it binds must still be named by the bundle's own objects (the store's
812
+ snapshot initialiser, its publish call), so a store that renames the field
813
+ reads as the step reporting the store rather than threading `undefined`.
814
+ Two things come free with the store: its setter takes functional updaters —
815
+ React's own contract, which the reducer splices already speak — and it
816
+ hides a finished block itself after 30 s, upstream's own linger.
791
817
 
792
818
  A render was once selected by *position* — the observation that the real sites
793
819
  fall after the state's `useState` declaration. That is worth recording as a
@@ -1245,10 +1271,10 @@ enumerated with their hashes in [corpus.md](corpus.md). Each removed matcher hit
1245
1271
  can still be running; each was carried as "kept for older builds", which is a
1246
1272
  claim the corpus disproves. The verbatim matchers were re-run from `HEAD` over
1247
1273
  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).
1274
+ measurements this section quotes were taken during the move across
1275
+ 2.1.210 → 2.1.233 (2.1.230 was never published) a span the corpus has since
1276
+ grown to hold in full, so they are re-checkable rather than historical, and
1277
+ re-running `doctor` over it is one command (below).
1252
1278
 
1253
1279
  - **`live-thinking` / `reducer-legacy`** — the pre-2.1.138 reducer with
1254
1280
  positional parameters. Its removal collapsed the `reducer` variant *group*
@@ -11,7 +11,8 @@ Before the first patch of a version, patch-cc copies the pristine binary to
11
11
  `~/.local/share/patch-cc/backups/<version>.orig` ([INTERNALS.md](INTERNALS.md#safety)).
12
12
  The corpus is exactly those copies: not a fixture checked into the repo (each is
13
13
  ~300 MB), but a set that **accretes on its own** as Claude auto-updates and you
14
- re-patch. `doctor <path>` reads one; the sweep below reads all of them.
14
+ re-patch and that can be backfilled by saving any pristine native binary under
15
+ the same name. `doctor <path>` reads one; the sweep below reads all of them.
15
16
 
16
17
  The binaries are the artifact, not this file — `doctor` recomputes every count
17
18
  from them, so nothing here can drift from what a matcher actually does. This
@@ -20,42 +21,52 @@ matches means the file changed under you, not that a number moved.
20
21
 
21
22
  ## On disk now
22
23
 
23
- The reproducible set on the machine this was written on 13 distinct binaries
24
- by content (the SHA-256 is the whole file, `sha256sum <version>.orig`):
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:
25
29
 
26
30
  | version | size | sha256 |
27
31
  |---|---|---|
32
+ | `2.1.210` | 261 MB | `e7d2ceb53ed4c2ced1fe7fc1c6331c98dc5f7b4c9b2722d9c5fa3dd5dff6f719` |
33
+ | `2.1.211` | 262 MB | `8272c8a474ac9ea1bc35f19b9f7c7e7dc4dc4eb6d5ad3e484b19335ac72446b2` |
34
+ | `2.1.212` | 264 MB | `044a88cf3a5180776617fd3da1238dcbf9141ddec449a39cf7d2af1ac78e684e` |
35
+ | `2.1.213` | 265 MB | `7999631426e1b6e4444e4ecf9cd8a63a05a0411ccfe503927d4c9d57bc41bc64` |
36
+ | `2.1.214` | 265 MB | `3c029136f7c81f54ed4a38e9d52e655aad536433dbbde50519c8c31bb646ad14` |
37
+ | `2.1.215` | 265 MB | `c1efffaaf370aa187cb6a09dd93d4e511c646899b0078476f83791b664bde7fe` |
28
38
  | `2.1.216` | 267 MB | `74deca45220b8080ec75ab099bd5a5980e41a2b5879846a008fb115d436de085` |
29
39
  | `2.1.217` | 269 MB | `2630fc5dc6db61bc03f86b95daf47766e5ed5b61873f7bb7cfea764c5ac5a9ba` |
30
40
  | `2.1.218` | 273 MB | `e12071751a9336b8af1012c103358ff04ac18f9aaff4a738cff7ba5cdfaf63f2` |
31
41
  | `2.1.219` | 275 MB | `22cfd6f5b3061c0391ba84e9cf8c9deaa37783aac18b004d42ec061e98f00691` |
32
42
  | `2.1.220` | 275 MB | `674f61f20ff306f3100cf9200e4c36c4b70278b5bef2884549819b942a89c863` |
33
43
  | `2.1.221` | 289 MB | `60db8e88d42c24b5199c92cfd56ec88370c510c3789c6f364af748354f087ada` |
44
+ | `2.1.222` | 289 MB | `10caae8f22b915c26bfff0e013a4d45608c4f1ae287583626569156f447730e5` |
34
45
  | `2.1.223` | 291 MB | `98226474f802e3094d6a86c5ade8883c16206d0fcb5c400b7401c800063e99d7` |
46
+ | `2.1.224` | 296 MB | `a2b5add7dc4bcd8eaa029f4e8bdac4df7769b4073698db7989d206baf9419c2d` |
47
+ | `2.1.225` | 298 MB | `0a3be8d18cb0f5357d38ce2d588601753a60b44cc9c622579ed8b8405dee231e` |
35
48
  | `2.1.226` | 298 MB | `4e9bec1177ce9690e8bd988b710ac24105e70da428dd094c5adcbbe786a55555` |
36
49
  | `2.1.227` | 304 MB | `6832dc3f1797b890b71116e5f2dbbf9a83fd3d0498c235b4b0f9cd0e6e499ad6` |
37
50
  | `2.1.228` | 309 MB | `d535985e6941a3eb00179ccd7f52ceb0c6623a0305a518ebc4e6514f84a94c99` |
51
+ | `2.1.229` | 311 MB | `200338139a3df04a9ad22233837d1fb53fb6dffa21cd82e47559bfaa115acc1b` |
52
+ | `2.1.231` | 311 MB | `47a01daebf794f6c86c13d1875ad6e5be0627029ad8600731161f24018ecde5b` |
53
+ | `2.1.232` | 323 MB | `61d23f8749136907d586d5b11831ea8a5234d4c1dea40a5e55c33b52e204c6d1` |
38
54
  | `2.1.233` | 325 MB | `55d281096f57d411ebbdd94dbf5e9ff3accb7c05713e37348c2c11d4b83bf9d9` |
39
55
  | `2.1.234` | 328 MB | `3473601ea695d5bf769c5b202844d4cb4fbf723ae995450fcb6973204775c84a` |
40
56
  | `2.1.235` | 331 MB | `bfcf0ae2dbf94b2b6a106074aabf3938b9a10889c3b678e4cb5a00c03274d5d5` |
57
+ | `2.1.236` | 335 MB | `6c8818fa22187aa555c242be4abbacc44d6b71a32ac9631ee7b2b5d12f51f752` |
41
58
 
42
- The backup directory holds a few more files than rows here, and that is not a
43
- discrepancy: a pre-0.2.0 backup doubled the version into its name
44
- (`2.1.216.2.1.216.orig`), a binary installed under a non-version name is
45
- saved as `claude.unknown-<hash>.orig`, and a binary patched under a
46
- non-canonical filename keeps that filename (`2.1.235` entered the corpus as a
47
- downloaded `claude-2.1.235`, so its backup is `claude-2.1.235.orig`). The two
48
- extra 2.1.216-era files hash-match the `2.1.216` row above — same bytes, a
49
- different filename — which is why the count of *files* (15) and the count of
50
- *distinct binaries* (13) differ.
59
+ 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.
51
62
 
52
63
  ## Rebuild or extend it
53
64
 
54
- There is nothing to download and no fixture to restore: the corpus is whatever
55
- pristine backups you have accumulated, and it grows every time patch-cc touches
56
- a new build. To widen it, run patch-cc across Claude updates — each first patch
57
- of a version leaves its `.orig`. To read the JS a given binary carries without
58
- patching anything:
65
+ The corpus grows every time patch-cc touches a new build each first patch of
66
+ a version leaves its `.orig` and a version this machine never patched joins
67
+ the same way: save its pristine native binary as
68
+ `~/.local/share/patch-cc/backups/<version>.orig`. To read the JS a given binary
69
+ carries without patching anything:
59
70
 
60
71
  ```bash
61
72
  patch-cc extract ~/.local/share/patch-cc/backups/2.1.233.orig > 2.1.233.js
@@ -75,12 +86,3 @@ sweep
75
86
 
76
87
  `doctor` is read-only — it runs the matchers and parses their output, and never
77
88
  writes a binary — so the sweep is safe to run against every backup at any time.
78
-
79
- ## Provenance of the wider span
80
-
81
- Some measurements in the playbook were taken during "the move to the tree"
82
- across a wider set than is durably on disk now — the range `2.1.210` → `2.1.233`
83
- (2.1.230 was never published), whose earliest builds predate this backup set.
84
- Those numbers were real when taken; the set above is what reproduces today, and
85
- a claim that names a build not listed here is a historical one this file does
86
- not stand behind byte-for-byte.
@@ -1,3 +1,3 @@
1
1
  """patch-cc: an interactive patcher for the Claude Code native binary."""
2
2
 
3
- __version__ = "0.3.2"
3
+ __version__ = "0.3.3"
@@ -132,6 +132,9 @@ _SETTER = "onStreamingThinking"
132
132
  #: `prop-threading` when 2.1.235 retired it -- and discriminates nothing on any
133
133
  #: build in the corpus: the pair already names the same one renderer.
134
134
  _TRANSCRIPT_SIGNATURE = ("messages", "streamingToolUses")
135
+ #: Our local for the threaded state, wherever one has to be minted: the
136
+ #: renderer signatures `transcript-signature` extends, and the store-snapshot
137
+ #: pattern `prop-threading` extends on builds that keep the state in a store.
135
138
  _INJECTED = "__cc_streamingThinking"
136
139
 
137
140
 
@@ -199,8 +202,10 @@ def _bound_in_scope(site: js.Node, prop: str) -> str | None:
199
202
  return None
200
203
 
201
204
 
202
- def _state_in_scope(site: js.Node) -> str | None:
203
- """The live-thinking state variable visible at this render, if any.
205
+ def _state_in_scope(site: js.Node) -> tuple[str, js.Node | None] | None:
206
+ """The live-thinking state at this render: the name to thread, and the
207
+ snapshot pattern to extend first when the state has to be read out of a
208
+ store rather than a binding upstream already made.
204
209
 
205
210
  Resolved from the site outwards, so what is threaded is a variable actually
206
211
  in scope where it is threaded -- the one thing the old matcher named as
@@ -211,34 +216,48 @@ def _state_in_scope(site: js.Node) -> str | None:
211
216
  gate could see it; it throws when that component renders.
212
217
 
213
218
  The state is identified by its setter: the scope that holds live thinking
214
- is the scope that hands ``onStreamingThinking`` to the reducer.
215
-
216
- Outwards is only half of lexical, and the half a subtree walk gets wrong:
217
- the declaration has to be one this render can *see* (:func:`js.visible`),
218
- which a nested function's is not and a block's own is not either. Threading
219
- one anyway produced a binary that parsed and threw -- the same failure as
220
- the bundle-wide answer this replaced, one door along -- and took the note
221
- that says a render was skipped with it. The setter is the other question and
222
- keeps the whole subtree: a scope may hand its setter to the reducer from
223
- inside a callback, and that says nothing about where the state lives.
224
-
225
- What the state is initialised to is not asked. ``useState(null)`` was the
226
- spelling on every build in the corpus and ``useState(void 0)`` is the same
227
- state, while the line below is the identity that matters: the pair whose
228
- second name is the setter this scope hands the reducer *is* the live
229
- thinking state, whatever produced it.
219
+ is the scope that hands ``onStreamingThinking`` to the reducer. The setter
220
+ is asked of the whole subtree on purpose -- a scope may hand it from inside
221
+ a callback, and that says nothing about where the state lives -- while the
222
+ binding threaded has to be one this render can *see* (:func:`js.visible`),
223
+ which a nested function's is not and a block's own is not either.
224
+
225
+ What the handed setter *is* has two upstream spellings, and each names the
226
+ state its own way:
227
+
228
+ - **A bare identifier** -- a ``useState`` setter, every build through
229
+ 2.1.235. The state is the array pattern that binds it: the pair whose
230
+ second name is the setter this scope hands the reducer *is* the live
231
+ thinking state, whatever produced it (``useState(null)`` and
232
+ ``useState(void 0)`` are the same state, so the initialiser is never
233
+ asked).
234
+ - **A member read on a store** -- 2.1.236 moved the state into an external
235
+ stream store (``subscribe``/``getSnapshot``/``_publish``) and hands the
236
+ reducer ``<store>.setStreamingThinking``. The scope reads that store back
237
+ by destructuring the hook call it hands the same store to
238
+ (``{streamingToolUses:…}=useX(<store>)``), so the state is that pattern's
239
+ own ``streamingThinking`` binding: upstream's, the day it takes one --
240
+ the goal achieved, same as every other judged-on-achievement step -- and
241
+ until then ours, inserted into the pattern. The pattern is proven the
242
+ snapshot read by the store expression itself: the call's *only* argument
243
+ is the very expression the setter was read off, with one answer or none
244
+ (:func:`js.only`). Sole argument is deliberate -- a second argument is a
245
+ selector whose result is no longer the snapshot, and extending a pattern
246
+ of unknowable provenance binds ``undefined`` with every count green, so
247
+ that shape is refused loudly instead.
230
248
  """
231
249
  scope = js.climb(site, lambda n: n.type in js.FUNCTIONS)
232
250
  while scope is not None and not _outermost(scope):
233
- setters = {
234
- js.text(js.binding(value))
251
+ handed = [
252
+ value
235
253
  for node in js.every(
236
254
  scope,
237
255
  lambda n: n.type == "property_identifier" and js.text(n) == _SETTER,
238
256
  )
239
257
  if (pair := js.named(node)) is not None
240
258
  and (value := pair.child_by_field_name("value")) is not None
241
- }
259
+ ]
260
+ setters = {js.text(js.binding(value)) for value in handed}
242
261
  for declarator in js.every(scope, js.of_type("variable_declarator")):
243
262
  name = declarator.child_by_field_name("name")
244
263
  if name is None or name.type != "array_pattern":
@@ -247,7 +266,33 @@ def _state_in_scope(site: js.Node) -> str | None:
247
266
  continue
248
267
  bound = [js.text(child) for child in name.named_children]
249
268
  if len(bound) == 2 and bound[1] in setters:
250
- return bound[0]
269
+ return bound[0], None
270
+ snapshots: dict[int, js.Node] = {}
271
+ for setter in (v for v in handed if v.type == "member_expression"):
272
+ store = js.text(js.receiver(setter))
273
+ for declarator in js.every(scope, js.of_type("variable_declarator")):
274
+ name = declarator.child_by_field_name("name")
275
+ value = declarator.child_by_field_name("value")
276
+ if name is None or name.type != "object_pattern":
277
+ continue
278
+ if not js.children(name) or value is None:
279
+ continue
280
+ if value.type != "call_expression":
281
+ continue
282
+ taken = js.arguments(value)
283
+ if len(taken) != 1 or js.text(taken[0]) != store:
284
+ continue
285
+ if not js.visible(declarator, site):
286
+ continue
287
+ snapshots[declarator.start_byte] = name
288
+ pattern = js.only(
289
+ list(snapshots.values()), "snapshot reads of the live-thinking store"
290
+ )
291
+ if pattern is not None:
292
+ carried = js.props(pattern)
293
+ if _STREAMING in carried:
294
+ return js.text(js.binding(carried[_STREAMING])), None
295
+ return _INJECTED, pattern
251
296
  scope = js.climb(scope.parent, lambda n: n.type in js.FUNCTIONS)
252
297
  return None
253
298
 
@@ -257,22 +302,55 @@ def _step_prop_threading(source: Source, outcome: Outcome) -> Source:
257
302
  step = outcome.step("prop-threading")
258
303
  edits = []
259
304
  renders = _conversation_renders(source)
260
- # Printed every run, green ones included: an early warning held back until
261
- # something breaks arrives too late to be one.
262
- step.note(f"{len(renders)} conversation render(s)")
263
-
305
+ # The durable witness behind a snapshot insertion: the field it binds is
306
+ # one the bundle's own objects still name (the store's snapshot initialiser
307
+ # and its publish call, on 2.1.236). Extending the pattern is a rewrite the
308
+ # matcher can only vouch for itself, and a store that renames the field
309
+ # would leave it threading `undefined` with every count green -- the same
310
+ # hole `thinking-summaries` pays a header-name count for.
311
+ field_named = any(
312
+ (pair := js.named(node)) is not None and pair.type == "pair"
313
+ for node in source.find(_STREAMING)
314
+ )
315
+ extended: set[int] = set()
316
+ unreached = 0
264
317
  for bag in renders:
265
- state = _state_in_scope(bag)
266
- if state is None:
267
- step.note("a conversation render has no live-thinking state in scope")
318
+ resolved = _state_in_scope(bag)
319
+ if resolved is None:
320
+ # By design on every build we hold: the resume view, the transcript
321
+ # overlay and the message picker draw conversations too, in scopes
322
+ # where nothing ever streams. Counted rather than worth a sentence
323
+ # each -- the note below carries the number, and the number moving
324
+ # is the signal (a render newly skipped was the only sign of the
325
+ # 2.1.232 out-of-scope threading).
326
+ unreached += 1
327
+ continue
328
+ state, pattern = resolved
329
+ if pattern is not None and not field_named:
330
+ step.note(f"the stream store no longer names a {_STREAMING} field")
268
331
  continue
269
332
  step.candidates += 1
270
333
  step.applied += 1
271
- edits.append(
272
- Edit.before(
273
- js.entry(js.props(bag)[_CONVERSATION[0]]), f"{_STREAMING}:{state},"
334
+ carried = js.props(bag)
335
+ if _STREAMING in carried:
336
+ # Upstream already threads it into this render; the goal achieved.
337
+ continue
338
+ if pattern is not None and pattern.start_byte not in extended:
339
+ # Two renders in one scope read one snapshot: the pattern gains the
340
+ # field once, at the front, which is valid whatever it ends with.
341
+ extended.add(pattern.start_byte)
342
+ edits.append(
343
+ Edit.before(pattern.named_children[0], f"{_STREAMING}:{state},")
274
344
  )
345
+ edits.append(
346
+ Edit.before(js.entry(carried[_CONVERSATION[0]]), f"{_STREAMING}:{state},")
275
347
  )
348
+ # Printed every run, green ones included: an early warning held back until
349
+ # something breaks arrives too late to be one.
350
+ step.note(
351
+ f"{len(renders)} conversation render(s)"
352
+ + (f", {unreached} with no live-thinking state in scope" if unreached else "")
353
+ )
276
354
  return source.apply(edits)
277
355
 
278
356
 
@@ -473,19 +551,34 @@ def _step_final_summary(source: Source, outcome: Outcome) -> Source:
473
551
  if not js.reads(thinking, _SUMMARY_PROPS[0]):
474
552
  continue
475
553
  block = js.text(js.receiver(thinking))
476
- guard = js.up(summary, "if_statement")
477
- if guard is None:
478
- continue
479
- condition = guard.child_by_field_name("condition")
480
- scope = js.climb(guard, lambda n: n.type in js.FUNCTIONS)
481
- if condition is None or scope is None:
554
+ # The block's home is the function that produced it -- the one holding
555
+ # the `.find` whose predicate selects thinking -- resolved from the
556
+ # summary outwards, since the summary itself sits inside the callback
557
+ # that hands it over.
558
+ scope = js.climb(summary, lambda n: n.type in js.FUNCTIONS)
559
+ predicate = None
560
+ while scope is not None and not _outermost(scope):
561
+ if (predicate := _selects_thinking(scope, block)) is not None:
562
+ break
563
+ scope = js.climb(scope.parent, lambda n: n.type in js.FUNCTIONS)
564
+ if predicate is None or scope is None:
482
565
  continue
483
- # The two tests that reject a redacted block: the one inside the `.find`
484
- # that produced it, and the one this summary is guarded by. Each is a
485
- # node; none of the code between them is.
486
- predicate = _selects_thinking(scope, block)
487
- test = _tests_thinking(condition, block)
488
- if predicate is None or test is None:
566
+ # Which `if` guards the summary is answered by what its condition
567
+ # tests, never by being the nearest: 2.1.236 nested an unrelated gate
568
+ # between the thinking test and the summary it guards, and the nearest
569
+ # `if` read a shape that had merely moved as one that was gone. The
570
+ # climb stays inside the block's own home, because its name is only a
571
+ # spelling until the scope it belongs to is said.
572
+ guard = js.up(summary, "if_statement")
573
+ test = None
574
+ while (
575
+ guard is not None
576
+ and guard.start_byte >= scope.start_byte
577
+ and (test := _tests_thinking(guard.child_by_field_name("condition"), block))
578
+ is None
579
+ ):
580
+ guard = js.up(guard.parent, "if_statement")
581
+ if test is None:
489
582
  continue
490
583
  step.candidates += 1
491
584
  step.applied += 1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes